Browse Source

implement RSS feed and make some corrections

master
Silberengel 1 month ago
parent
commit
ae2eff0b06
  1. 4
      src/lib/components/layout/Header.svelte
  2. 578
      src/lib/components/preferences/UserPreferences.svelte
  3. 29
      src/lib/components/write/CreateEventForm.svelte
  4. 15
      src/lib/modules/comments/CommentForm.svelte
  5. 4
      src/lib/modules/reactions/FeedReactionButtons.svelte
  6. 4
      src/lib/modules/reactions/ReactionButtons.svelte
  7. 26
      src/lib/services/client-tag-preference.ts
  8. 32
      src/lib/services/event-expiration.ts
  9. 5
      src/routes/event/[id]/+page.svelte
  10. 228
      src/routes/rss/+page.svelte
  11. 146
      src/routes/rss/[pubkey]/+page.server.ts

4
src/lib/components/layout/Header.svelte

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
<script lang="ts">
import { sessionManager, type UserSession } from '../../services/auth/session-manager.js';
import ThemeToggle from '../preferences/ThemeToggle.svelte';
import UserPreferences from '../preferences/UserPreferences.svelte';
import ProfileBadge from '../layout/ProfileBadge.svelte';
@ -41,12 +40,12 @@ @@ -41,12 +40,12 @@
<a href="/feed" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Feed</a>
{#if isLoggedIn}
<a href="/write" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Write</a>
<a href="/rss" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">RSS</a>
{/if}
</div>
<div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm">
{#if isLoggedIn && currentPubkey}
<UserPreferences />
<ThemeToggle />
<ProfileBadge pubkey={currentPubkey} />
<button
onclick={() => sessionManager.clearSession()}
@ -59,7 +58,6 @@ @@ -59,7 +58,6 @@
{:else}
<a href="/login" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Login</a>
<UserPreferences />
<ThemeToggle />
{/if}
</div>
</div>

578
src/lib/components/preferences/UserPreferences.svelte

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { shouldIncludeClientTag, setIncludeClientTag } from '../../services/client-tag-preference.js';
type TextSize = 'small' | 'medium' | 'large';
type LineSpacing = 'tight' | 'normal' | 'loose';
@ -8,11 +10,16 @@ @@ -8,11 +10,16 @@
let textSize = $state<TextSize>('medium');
let lineSpacing = $state<LineSpacing>('normal');
let contentWidth = $state<ContentWidth>('medium');
let isDark = $state(false);
let expiringEvents = $state(false);
let mediaUploadServer = $state('https://nostr.build');
let includeClientTag = $state(true);
let showPreferences = $state(false);
onMount(() => {
loadPreferences();
applyPreferences();
loadTheme();
});
function loadPreferences() {
@ -21,10 +28,39 @@ @@ -21,10 +28,39 @@
const savedTextSize = localStorage.getItem('aitherboard_textSize') as TextSize | null;
const savedLineSpacing = localStorage.getItem('aitherboard_lineSpacing') as LineSpacing | null;
const savedContentWidth = localStorage.getItem('aitherboard_contentWidth') as ContentWidth | null;
const savedExpiringEvents = localStorage.getItem('aitherboard_expiringEvents');
const savedMediaUploadServer = localStorage.getItem('aitherboard_mediaUploadServer');
if (savedTextSize) textSize = savedTextSize;
if (savedLineSpacing) lineSpacing = savedLineSpacing;
if (savedContentWidth) contentWidth = savedContentWidth;
if (savedExpiringEvents === 'true') expiringEvents = true;
if (savedMediaUploadServer) mediaUploadServer = savedMediaUploadServer;
includeClientTag = shouldIncludeClientTag();
}
function loadTheme() {
if (typeof window === 'undefined') return;
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
isDark = stored === 'dark' || (!stored && prefersDark);
updateTheme();
}
function toggleTheme() {
isDark = !isDark;
updateTheme();
}
function updateTheme() {
if (typeof document === 'undefined') return;
if (isDark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}
function savePreferences() {
@ -33,6 +69,9 @@ @@ -33,6 +69,9 @@
localStorage.setItem('aitherboard_textSize', textSize);
localStorage.setItem('aitherboard_lineSpacing', lineSpacing);
localStorage.setItem('aitherboard_contentWidth', contentWidth);
localStorage.setItem('aitherboard_expiringEvents', expiringEvents ? 'true' : 'false');
localStorage.setItem('aitherboard_mediaUploadServer', mediaUploadServer);
setIncludeClientTag(includeClientTag);
applyPreferences();
}
@ -42,7 +81,7 @@ @@ -42,7 +81,7 @@
const root = document.documentElement;
// Text size - set both CSS variable and data attribute
// Text size
const textSizes = {
small: '14px',
medium: '16px',
@ -51,7 +90,7 @@ @@ -51,7 +90,7 @@
root.style.setProperty('--text-size', textSizes[textSize]);
root.setAttribute('data-text-size', textSize);
// Line spacing - set both CSS variable and data attribute
// Line spacing
const lineSpacings = {
tight: '1.4',
normal: '1.6',
@ -60,7 +99,7 @@ @@ -60,7 +99,7 @@
root.style.setProperty('--line-height', lineSpacings[lineSpacing]);
root.setAttribute('data-line-spacing', lineSpacing);
// Content width - set CSS variable and data attribute
// Content width
const contentWidths = {
narrow: '600px',
medium: '800px',
@ -70,14 +109,37 @@ @@ -70,14 +109,37 @@
root.setAttribute('data-content-width', contentWidth);
}
function handleMediaUploadServerChange(e: Event) {
const target = e.target as HTMLInputElement;
mediaUploadServer = target.value;
savePreferences();
}
function handleManageCache() {
showPreferences = false;
goto('/cache');
}
$effect(() => {
savePreferences();
});
$effect(() => {
if (showPreferences) {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
showPreferences = false;
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}
});
</script>
<button
onclick={() => (showPreferences = !showPreferences)}
class="px-3 py-1 rounded border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors"
class="preferences-button"
title="User Preferences"
aria-label="User Preferences"
>
@ -85,113 +147,182 @@ @@ -85,113 +147,182 @@
</button>
{#if showPreferences}
<!-- Backdrop -->
<div
class="preferences-modal"
onclick={(e) => e.target === e.currentTarget && (showPreferences = false)}
onkeydown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
showPreferences = false;
}
}}
role="dialog"
aria-modal="true"
aria-labelledby="preferences-title"
tabindex="-1"
>
<div class="preferences-content">
<div class="preferences-header">
<h2 id="preferences-title">User Preferences</h2>
<button onclick={() => (showPreferences = false)} class="close-button" aria-label="Close preferences">×</button>
class="preferences-backdrop"
onclick={() => (showPreferences = false)}
role="presentation"
></div>
<!-- Panel -->
<div class="preferences-panel" role="dialog" aria-modal="true" aria-labelledby="preferences-title">
<div class="preferences-header">
<h2 id="preferences-title">Preferences</h2>
<button onclick={() => (showPreferences = false)} class="close-button" aria-label="Close preferences">×</button>
</div>
<div class="preferences-body">
<!-- Theme Toggle -->
<fieldset class="preference-group">
<legend class="preference-label">Theme</legend>
<div class="preference-options" role="group" aria-label="Theme">
<button
onclick={toggleTheme}
class="preference-option theme-toggle"
aria-pressed={isDark}
>
<span class="emoji emoji-grayscale">{#if isDark}{:else}🌙{/if}</span>
<span>{isDark ? 'Light' : 'Dark'} Mode</span>
</button>
</div>
</fieldset>
<!-- Text Size -->
<fieldset class="preference-group">
<legend class="preference-label">Text Size</legend>
<div class="preference-options" role="group" aria-label="Text Size">
<button
onclick={() => (textSize = 'small')}
class="preference-option {textSize === 'small' ? 'active' : ''}"
aria-pressed={textSize === 'small'}
>
Small
</button>
<button
onclick={() => (textSize = 'medium')}
class="preference-option {textSize === 'medium' ? 'active' : ''}"
aria-pressed={textSize === 'medium'}
>
Medium
</button>
<button
onclick={() => (textSize = 'large')}
class="preference-option {textSize === 'large' ? 'active' : ''}"
aria-pressed={textSize === 'large'}
>
Large
</button>
</div>
</fieldset>
<!-- Line Spacing -->
<fieldset class="preference-group">
<legend class="preference-label">Line Spacing</legend>
<div class="preference-options" role="group" aria-label="Line Spacing">
<button
onclick={() => (lineSpacing = 'tight')}
class="preference-option {lineSpacing === 'tight' ? 'active' : ''}"
aria-pressed={lineSpacing === 'tight'}
>
Tight
</button>
<button
onclick={() => (lineSpacing = 'normal')}
class="preference-option {lineSpacing === 'normal' ? 'active' : ''}"
aria-pressed={lineSpacing === 'normal'}
>
Normal
</button>
<button
onclick={() => (lineSpacing = 'loose')}
class="preference-option {lineSpacing === 'loose' ? 'active' : ''}"
aria-pressed={lineSpacing === 'loose'}
>
Loose
</button>
</div>
</fieldset>
<!-- Expiring Events -->
<fieldset class="preference-group">
<legend class="preference-label">Event Expiration</legend>
<label class="checkbox-label">
<input
type="checkbox"
bind:checked={expiringEvents}
class="checkbox-input"
/>
<span>Create expiring events (6 months: kinds 7, 1, 30315)</span>
</label>
<p class="preference-help">Adds a 6-month expiration timestamp to created events of the specified kinds.</p>
</fieldset>
<!-- Include Client Tag -->
<fieldset class="preference-group">
<legend class="preference-label">Client Tag</legend>
<label class="checkbox-label">
<input
type="checkbox"
bind:checked={includeClientTag}
class="checkbox-input"
/>
<span>Include client tag</span>
</label>
<p class="preference-help">Include NIP-89 client tag in published events to identify the client used.</p>
</fieldset>
<!-- Media Upload Server -->
<fieldset class="preference-group">
<legend class="preference-label">Media Upload Server</legend>
<input
type="text"
bind:value={mediaUploadServer}
oninput={handleMediaUploadServerChange}
placeholder="https://nostr.build"
class="text-input"
/>
<p class="preference-help">Preferred server for uploading media files (NIP-96).</p>
</fieldset>
<!-- Manage Cache Button -->
<div class="preference-group">
<button
onclick={handleManageCache}
class="manage-cache-button"
>
Manage Cache
</button>
</div>
</div>
<div class="preferences-body">
<fieldset class="preference-group">
<legend class="preference-label">Text Size</legend>
<div class="preference-options" role="group" aria-label="Text Size">
<button
onclick={() => (textSize = 'small')}
class="preference-option {textSize === 'small' ? 'active' : ''}"
aria-pressed={textSize === 'small'}
>
Small
</button>
<button
onclick={() => (textSize = 'medium')}
class="preference-option {textSize === 'medium' ? 'active' : ''}"
aria-pressed={textSize === 'medium'}
>
Medium
</button>
<button
onclick={() => (textSize = 'large')}
class="preference-option {textSize === 'large' ? 'active' : ''}"
aria-pressed={textSize === 'large'}
>
Large
</button>
</div>
</fieldset>
<fieldset class="preference-group">
<legend class="preference-label">Line Spacing</legend>
<div class="preference-options" role="group" aria-label="Line Spacing">
<button
onclick={() => (lineSpacing = 'tight')}
class="preference-option {lineSpacing === 'tight' ? 'active' : ''}"
aria-pressed={lineSpacing === 'tight'}
>
Tight
</button>
<button
onclick={() => (lineSpacing = 'normal')}
class="preference-option {lineSpacing === 'normal' ? 'active' : ''}"
aria-pressed={lineSpacing === 'normal'}
>
Normal
</button>
<button
onclick={() => (lineSpacing = 'loose')}
class="preference-option {lineSpacing === 'loose' ? 'active' : ''}"
aria-pressed={lineSpacing === 'loose'}
>
Loose
</button>
</div>
</fieldset>
<fieldset class="preference-group">
<legend class="preference-label">Content Width</legend>
<div class="preference-options" role="group" aria-label="Content Width">
<button
onclick={() => (contentWidth = 'narrow')}
class="preference-option {contentWidth === 'narrow' ? 'active' : ''}"
aria-pressed={contentWidth === 'narrow'}
>
Narrow
</button>
<button
onclick={() => (contentWidth = 'medium')}
class="preference-option {contentWidth === 'medium' ? 'active' : ''}"
aria-pressed={contentWidth === 'medium'}
>
Medium
</button>
<button
onclick={() => (contentWidth = 'wide')}
class="preference-option {contentWidth === 'wide' ? 'active' : ''}"
aria-pressed={contentWidth === 'wide'}
>
Wide
</button>
</div>
</fieldset>
<!-- About Section -->
<div class="preferences-footer">
<div class="about-section">
<h3 class="about-title">About</h3>
<p class="about-text">
Aitherboard is a decentralized discussion board built on Nostr.
Brought to you by <a href="/profile/fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1" class="about-link">Silberengel</a>.
</p>
</div>
</div>
</div>
{/if}
<style>
.preferences-button {
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
border: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
cursor: pointer;
transition: all 0.2s;
}
:global(.dark) .preferences-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.preferences-button:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .preferences-button:hover {
background: var(--fog-dark-highlight, #374151);
}
.emoji {
font-size: 1rem;
line-height: 1;
@ -199,11 +330,7 @@ @@ -199,11 +330,7 @@
filter: grayscale(100%);
}
button:hover .emoji {
filter: grayscale(80%);
}
.preferences-modal {
.preferences-backdrop {
position: fixed;
top: 0;
left: 0;
@ -211,26 +338,48 @@ @@ -211,26 +338,48 @@
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 999;
animation: fadeIn 0.2s ease-out;
}
.preferences-content {
.preferences-panel {
position: fixed;
top: 0;
left: 0;
width: 320px;
height: 100vh;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border-right: 1px solid var(--fog-border, #e5e7eb);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out;
overflow: hidden;
}
:global(.dark) .preferences-content {
:global(.dark) .preferences-panel {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
border-right-color: var(--fog-dark-border, #374151);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.3);
}
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.preferences-header {
@ -239,12 +388,24 @@ @@ -239,12 +388,24 @@
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .preferences-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.preferences-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .preferences-header h2 {
color: var(--fog-dark-text, #f9fafb);
}
.close-button {
background: none;
border: none;
@ -253,10 +414,30 @@ @@ -253,10 +414,30 @@
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--fog-text, #1f2937);
border-radius: 0.25rem;
transition: background 0.2s;
}
:global(.dark) .close-button {
color: var(--fog-dark-text, #f9fafb);
}
.close-button:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .close-button:hover {
background: var(--fog-dark-highlight, #374151);
}
.preferences-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.preference-group {
@ -271,6 +452,7 @@ @@ -271,6 +452,7 @@
margin-bottom: 0.5rem;
color: var(--fog-text, #1f2937);
padding: 0;
font-size: 0.875rem;
}
:global(.dark) .preference-label {
@ -282,22 +464,6 @@ @@ -282,22 +464,6 @@
flex-wrap: wrap;
gap: 0.5rem;
}
@media (max-width: 768px) {
.preferences-content {
width: 95%;
max-height: 90vh;
}
.preference-options {
gap: 0.375rem;
}
.preference-option {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
}
.preference-option {
padding: 0.5rem 1rem;
@ -307,6 +473,13 @@ @@ -307,6 +473,13 @@
color: var(--fog-text, #1f2937);
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
}
.preference-option.theme-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
}
:global(.dark) .preference-option {
@ -326,13 +499,136 @@ @@ -326,13 +499,136 @@
.preference-option.active {
background: var(--fog-accent, #64748b);
color: var(--fog-text, #475569);
color: white;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .preference-option.active {
background: var(--fog-dark-accent, #64748b);
color: var(--fog-dark-text, #cbd5e1);
border-color: var(--fog-dark-accent, #64748b);
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #1f2937);
border-color: var(--fog-dark-accent, #94a3b8);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .checkbox-label {
color: var(--fog-dark-text, #f9fafb);
}
.checkbox-input {
width: 1rem;
height: 1rem;
cursor: pointer;
}
.preference-help {
margin: 0.5rem 0 0 0;
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
font-style: italic;
}
:global(.dark) .preference-help {
color: var(--fog-dark-text-light, #9ca3af);
}
.text-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .text-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.manage-cache-button {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-accent, #64748b);
color: white;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
:global(.dark) .manage-cache-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-accent, #94a3b8);
}
.manage-cache-button:hover {
opacity: 0.9;
}
.preferences-footer {
padding: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .preferences-footer {
border-top-color: var(--fog-dark-border, #374151);
}
.about-section {
font-size: 0.75rem;
}
.about-title {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .about-title {
color: var(--fog-dark-text, #f9fafb);
}
.about-text {
margin: 0;
color: var(--fog-text-light, #6b7280);
line-height: 1.5;
}
:global(.dark) .about-text {
color: var(--fog-dark-text-light, #9ca3af);
}
.about-link {
color: var(--fog-accent, #64748b);
text-decoration: underline;
}
:global(.dark) .about-link {
color: var(--fog-dark-accent, #94a3b8);
}
.about-link:hover {
opacity: 0.8;
}
@media (max-width: 768px) {
.preferences-panel {
width: 100%;
max-width: 320px;
}
}
</style>

29
src/lib/components/write/CreateEventForm.svelte

@ -21,6 +21,7 @@ @@ -21,6 +21,7 @@
{ value: 30041, label: '30041 - AsciiDoc' },
{ value: 30040, label: '30040 - Event Index (metadata-only)' },
{ value: 1068, label: '1068 - Poll' },
{ value: 10895, label: '10015 - RSS Feed' },
{ value: -1, label: 'Unknown Kind' }
];
@ -45,7 +46,15 @@ @@ -45,7 +46,15 @@
}
});
// Clear content for metadata-only kinds
$effect(() => {
if (selectedKind === 30040 || selectedKind === 10895) {
content = '';
}
});
const isKind30040 = $derived(selectedKind === 30040);
const isKind10895 = $derived(selectedKind === 10895);
const isUnknownKind = $derived(selectedKind === -1);
const effectiveKind = $derived(isUnknownKind ? (parseInt(customKindId) || 1) : selectedKind);
@ -112,6 +121,11 @@ @@ -112,6 +121,11 @@
description: 'A poll event. Content is the poll label/question. Options and settings are in tags.',
suggestedTags: ['option (optionId, label)', 'relay (one or more)', 'polltype (singlechoice|multiplechoice)', 'endsAt (unix timestamp)']
};
case 10895:
return {
description: 'RSS Feed subscription event. Lists external RSS feeds to subscribe to. Content should be empty.',
suggestedTags: ['u (RSS feed URL, repeat for multiple feeds)']
};
default:
return {
description: `Custom kind ${kind}. Refer to the relevant NIP specification for tag requirements.`,
@ -321,6 +335,19 @@ @@ -321,6 +335,19 @@
id: '...',
sig: '...'
}, null, 2);
case 10895:
return JSON.stringify({
kind: 10895,
pubkey: examplePubkey,
created_at: timestamp,
content: '',
tags: [
['u', 'https://example.com/feed.rss'],
['u', 'https://another-site.com/rss.xml']
],
id: '...',
sig: '...'
}, null, 2);
default:
return JSON.stringify({
kind: kind,
@ -493,7 +520,7 @@ @@ -493,7 +520,7 @@
{/if}
</div>
{#if !isKind30040}
{#if !isKind30040 && !isKind10895}
<div class="form-group">
<label for="content-textarea" class="form-label">Content</label>
<textarea

15
src/lib/modules/comments/CommentForm.svelte

@ -10,6 +10,7 @@ @@ -10,6 +10,7 @@
import { insertTextAtCursor } from '../../services/text-utils.js';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
interface Props {
threadId: string; // The root event ID
@ -23,7 +24,6 @@ @@ -23,7 +24,6 @@
let content = $state('');
let publishing = $state(false);
let includeClientTag = $state(true);
let showStatusModal = $state(false);
let publicationResults: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = $state(null);
let showGifPicker = $state(false);
@ -113,7 +113,7 @@ @@ -113,7 +113,7 @@
}
}
if (includeClientTag) {
if (shouldIncludeClientTag()) {
tags.push(['client', 'Aitherboard']);
}
@ -219,16 +219,7 @@ @@ -219,16 +219,7 @@
{/if}
</div>
<div class="flex items-center justify-between mt-2">
<label class="flex items-center gap-2 text-sm text-fog-text-light dark:text-fog-dark-text-light opacity-70">
<input
type="checkbox"
bind:checked={includeClientTag}
class="rounded client-tag-checkbox"
/>
Include client tag
</label>
<div class="flex items-center justify-end mt-2">
<div class="flex gap-2">
{#if onCancel}
<button

4
src/lib/modules/reactions/FeedReactionButtons.svelte

@ -9,6 +9,7 @@ @@ -9,6 +9,7 @@
import { resolveCustomEmojis, fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js';
import EmojiPicker from '../../components/content/EmojiPicker.svelte';
import emojiData from 'unicode-emoji-json/data-ordered-emoji.json';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
interface Props {
event: NostrEvent;
@ -481,7 +482,7 @@ @@ -481,7 +482,7 @@
['k', event.kind.toString()]
];
if (sessionManager.getCurrentPubkey() && includeClientTag) {
if (sessionManager.getCurrentPubkey() && shouldIncludeClientTag()) {
tags.push(['client', 'Aitherboard']);
}
@ -632,7 +633,6 @@ @@ -632,7 +633,6 @@
}
});
let includeClientTag = $state(true);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
</script>

4
src/lib/modules/reactions/ReactionButtons.svelte

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
interface Props {
event: NostrEvent; // The event to react to (kind 11 or 1111)
@ -121,7 +122,7 @@ @@ -121,7 +122,7 @@
['k', event.kind.toString()]
];
if (sessionManager.getCurrentPubkey() && includeClientTag) {
if (sessionManager.getCurrentPubkey() && shouldIncludeClientTag()) {
tags.push(['client', 'Aitherboard']);
}
@ -164,7 +165,6 @@ @@ -164,7 +165,6 @@
return reactions.get(content)?.pubkeys.size || 0;
}
let includeClientTag = $state(true);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
</script>

26
src/lib/services/client-tag-preference.ts

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
/**
* Client tag preference utility
* Manages the global preference for including client tags in events
*/
const STORAGE_KEY = 'aitherboard_includeClientTag';
/**
* Get the current preference for including client tags
* @returns true if client tags should be included (default: true)
*/
export function shouldIncludeClientTag(): boolean {
if (typeof window === 'undefined') return true;
const stored = localStorage.getItem(STORAGE_KEY);
// Default to true if not set
return stored === null || stored === 'true';
}
/**
* Set the preference for including client tags
* @param include - Whether to include client tags
*/
export function setIncludeClientTag(include: boolean): void {
if (typeof window === 'undefined') return;
localStorage.setItem(STORAGE_KEY, include ? 'true' : 'false');
}

32
src/lib/services/event-expiration.ts

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
/**
* Event expiration utility service
* Handles logic for adding expiration tags to events
*/
/**
* Check if an event kind should have an expiration tag added
* @param kind - The event kind number
* @returns true if the kind should have expiration (kinds 7, 1, 30315)
*/
export function shouldAddExpiration(kind: number): boolean {
return kind === 7 || kind === 1 || kind === 30315;
}
/**
* Get the expiration timestamp (6 months from now)
* @returns Unix timestamp in seconds
*/
export function getExpirationTimestamp(): number {
const sixMonthsInSeconds = 6 * 30 * 24 * 60 * 60; // 6 months = 15552000 seconds
return Math.floor(Date.now() / 1000) + sixMonthsInSeconds;
}
/**
* Check if expiring events are enabled in user preferences
* @returns true if the user has enabled expiring events
*/
export function hasExpiringEventsEnabled(): boolean {
if (typeof window === 'undefined') return false;
const stored = localStorage.getItem('aitherboard_expiringEvents');
return stored === 'true';
}

5
src/routes/event/[id]/+page.svelte

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { getKindInfo, isReplaceableKind, isParameterizedReplaceableKind, KIND } from '../../../lib/types/kind-lookup.js';
import { shouldIncludeClientTag } from '../../../lib/services/client-tag-preference.js';
let event = $state<NostrEvent | null>(null);
let loading = $state(true);
@ -194,7 +195,9 @@ @@ -194,7 +195,9 @@
}
// Add client tag (NIP-89)
tags.push(['client', 'Aitherboard']);
if (shouldIncludeClientTag()) {
tags.push(['client', 'Aitherboard']);
}
const eventTemplate = {
kind: KIND.LABEL, // 1985

228
src/routes/rss/+page.svelte

@ -0,0 +1,228 @@ @@ -0,0 +1,228 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { KIND } from '../../lib/types/kind-lookup.js';
const RSS_FEED_KIND = 10895;
let currentPubkey = $state<string | null>(null);
let rssEvent = $state<NostrEvent | null>(null);
let loading = $state(true);
let subscribedFeeds = $derived.by(() => {
if (!rssEvent) return [];
return rssEvent.tags
.filter(tag => tag[0] === 'u' && tag[1])
.map(tag => tag[1]);
});
onMount(async () => {
await nostrClient.initialize();
const session = sessionManager.getSession();
if (!session) {
goto('/login');
return;
}
currentPubkey = session.pubkey;
await checkRssEvent();
});
async function checkRssEvent() {
if (!currentPubkey) return;
loading = true;
try {
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [RSS_FEED_KIND], authors: [currentPubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
rssEvent = events[0];
}
} catch (error) {
console.error('Error checking RSS event:', error);
} finally {
loading = false;
}
}
function handleCreateRss() {
goto(`/write?kind=${RSS_FEED_KIND}`);
}
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="rss-page">
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">RSS Feed</h1>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading...</p>
{:else if !rssEvent}
<div class="rss-setup">
<p class="mb-4 text-fog-text dark:text-fog-dark-text">
You don't have an RSS feed subscription event yet. Create a kind {RSS_FEED_KIND} event to subscribe to external RSS feeds.
</p>
<p class="mb-4 text-sm text-fog-text-light dark:text-fog-dark-text-light">
Add "u" tags with RSS feed URLs to subscribe to external feeds.
</p>
<button
onclick={handleCreateRss}
class="create-rss-button"
>
Create RSS Feed Subscription Event
</button>
</div>
{:else}
<div class="rss-info">
<h2 class="text-xl font-semibold mb-4 text-fog-text dark:text-fog-dark-text">Subscribed RSS Feeds</h2>
{#if subscribedFeeds.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">
No RSS feeds subscribed. Edit your kind {RSS_FEED_KIND} event to add "u" tags with RSS feed URLs.
</p>
{:else}
<ul class="rss-feed-list">
{#each subscribedFeeds as feedUrl}
<li class="rss-feed-item">
<a
href={feedUrl}
target="_blank"
rel="noopener noreferrer"
class="rss-feed-link"
>
{feedUrl}
</a>
</li>
{/each}
</ul>
{/if}
<div class="mt-6 pt-6 border-t border-fog-border dark:border-fog-dark-border">
<p class="mb-2 text-sm text-fog-text dark:text-fog-dark-text">
To add or remove feeds, edit your kind {RSS_FEED_KIND} event.
</p>
<a
href="/write?kind={RSS_FEED_KIND}"
class="edit-rss-button"
>
Edit RSS Feed Event
</a>
</div>
</div>
{/if}
</div>
</main>
<style>
.rss-page {
max-width: 600px;
}
.rss-setup {
padding: 2rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
}
:global(.dark) .rss-setup {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.create-rss-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
:global(.dark) .create-rss-button {
background: var(--fog-dark-accent, #94a3b8);
}
.create-rss-button:hover {
opacity: 0.9;
}
.rss-info {
padding: 2rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
}
:global(.dark) .rss-info {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.rss-feed-list {
list-style: none;
padding: 0;
margin: 0;
}
.rss-feed-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
}
:global(.dark) .rss-feed-item {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.rss-feed-link {
color: var(--fog-accent, #64748b);
text-decoration: none;
word-break: break-all;
font-size: 0.875rem;
}
:global(.dark) .rss-feed-link {
color: var(--fog-dark-accent, #94a3b8);
}
.rss-feed-link:hover {
text-decoration: underline;
}
.edit-rss-button {
display: inline-block;
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
transition: opacity 0.2s;
}
:global(.dark) .edit-rss-button {
background: var(--fog-dark-accent, #94a3b8);
}
.edit-rss-button:hover {
opacity: 0.9;
}
</style>

146
src/routes/rss/[pubkey]/+page.server.ts

@ -0,0 +1,146 @@ @@ -0,0 +1,146 @@
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { getEventsByPubkey } from '../../../lib/services/cache/event-cache.js';
import { stripMarkdown } from '../../../lib/services/text-utils.js';
import type { RequestHandler } from './$types';
import { KIND } from '../../../lib/types/kind-lookup.js';
const RSS_FEED_KIND = 10015;
export const GET: RequestHandler = async ({ params, url }) => {
const pubkey = params.pubkey;
if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) {
return new Response('Invalid pubkey', { status: 400 });
}
try {
// Initialize nostr client if needed
await nostrClient.initialize();
// Fetch user's events for RSS feed
// Include kinds: 1 (notes), 11 (threads), 30023 (long-form)
const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents(
[
{
kinds: [KIND.SHORT_TEXT_NOTE, KIND.DISCUSSION_THREAD, 30023],
authors: [pubkey],
limit: 50
}
],
relays,
{ useCache: true, cacheResults: false }
);
// Sort by created_at, newest first
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at);
// Get profile for feed metadata
const profileEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: false }
);
let profileName = pubkey.substring(0, 16) + '...';
let profileAbout = '';
let profilePicture = '';
if (profileEvents.length > 0) {
try {
const profile = JSON.parse(profileEvents[0].content);
profileName = profile.name || profile.display_name || profileName;
profileAbout = profile.about || '';
profilePicture = profile.picture || '';
} catch (e) {
// Invalid JSON, use defaults
}
}
const baseUrl = url.origin;
const feedUrl = `${baseUrl}/rss/${pubkey}.xml`;
// Generate RSS XML
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>${escapeXml(profileName)}'s Nostr Feed</title>
<link>${baseUrl}/profile/${pubkey}</link>
<description>${escapeXml(profileAbout || `Nostr feed for ${profileName}`)}</description>
<language>en</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<generator>Aitherboard</generator>
${profilePicture ? `<image><url>${escapeXml(profilePicture)}</url><title>${escapeXml(profileName)}</title><link>${baseUrl}/profile/${pubkey}</link></image>` : ''}
${sortedEvents.map(event => {
const title = getEventTitle(event);
const content = stripMarkdown(event.content);
const description = content.length > 500 ? content.substring(0, 500) + '...' : content;
const link = `${baseUrl}/event/${event.id}`;
const pubDate = new Date(event.created_at * 1000).toUTCString();
const guid = event.id;
return ` <item>
<title>${escapeXml(title)}</title>
<link>${escapeXml(link)}</link>
<guid isPermaLink="false">${escapeXml(guid)}</guid>
<pubDate>${pubDate}</pubDate>
<dc:creator>${escapeXml(profileName)}</dc:creator>
<description>${escapeXml(description)}</description>
<content:encoded><![CDATA[${event.content}]]></content:encoded>
</item>`;
}).join('\n')}
</channel>
</rss>`;
return new Response(rssXml, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=300'
}
});
} catch (error) {
console.error('Error generating RSS feed:', error);
return new Response('Error generating RSS feed', { status: 500 });
}
};
function escapeXml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function getEventTitle(event: any): string {
// Try to get title from tags
const titleTag = event.tags.find((t: string[]) => t[0] === 'title');
if (titleTag && titleTag[1]) {
return titleTag[1];
}
// For kind 1, use first line or first 100 chars
if (event.kind === KIND.SHORT_TEXT_NOTE) {
const firstLine = event.content.split('\n')[0];
return firstLine.length > 100 ? firstLine.substring(0, 100) + '...' : firstLine || 'Untitled Note';
}
// For kind 11, try to get from content
if (event.kind === KIND.DISCUSSION_THREAD) {
const firstLine = event.content.split('\n')[0];
return firstLine.length > 100 ? firstLine.substring(0, 100) + '...' : firstLine || 'Untitled Thread';
}
// For kind 30023, try title tag or first line
if (event.kind === 30023) {
const firstLine = event.content.split('\n')[0];
if (firstLine.startsWith('# ')) {
return firstLine.substring(2);
}
return firstLine.length > 100 ? firstLine.substring(0, 100) + '...' : firstLine || 'Untitled Article';
}
return 'Untitled';
}
Loading…
Cancel
Save