diff --git a/src/lib/components/content/ReplyContext.svelte b/src/lib/components/content/ReplyContext.svelte index 4c89b4e..87a9e89 100644 --- a/src/lib/components/content/ReplyContext.svelte +++ b/src/lib/components/content/ReplyContext.svelte @@ -4,16 +4,20 @@ import { relayManager } from '../../services/nostr/relay-manager.js'; import { stripMarkdown } from '../../services/text-utils.js'; import { KIND } from '../../types/kind-lookup.js'; + import { getEventLink } from '../../services/event-links.js'; + import { goto } from '$app/navigation'; + import IconButton from '../ui/IconButton.svelte'; interface Props { parentEvent?: NostrEvent; // Optional - if not provided, will load by parentEventId parentEventId?: string; // Optional - used to load parent if not provided + parentEventTagType?: 'e' | 'E' | 'a' | 'A' | 'i' | 'I' | 'q'; // Tag type for loading the event targetId?: string; // Optional ID to scroll to (defaults to parent event ID) onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer } - let { parentEvent: providedParentEvent, parentEventId, targetId, onParentLoaded, onOpenEvent }: Props = $props(); + let { parentEvent: providedParentEvent, parentEventId, parentEventTagType, targetId, onParentLoaded, onOpenEvent }: Props = $props(); let loadedParentEvent = $state(null); let loadingParent = $state(false); @@ -31,31 +35,73 @@ return; } - // Determine which event ID we need to load - const eventIdToLoad = parentEventId || parentEvent?.id; + // Only use parentEventId prop, not the derived parentEvent.id + // This prevents reactive loops + const eventIdToLoad = parentEventId; // If no provided parent event and we have an ID, try to load it - // Only load if we haven't already tried to load this ID + // Only load if we haven't already tried to load this ID and we don't have a loaded event if (eventIdToLoad && !loadedParentEvent && !loadingParent && lastLoadAttemptId !== eventIdToLoad) { loadParentEvent(eventIdToLoad); } }); - async function loadParentEvent(eventId: string) { - if (!eventId || loadingParent || lastLoadAttemptId === eventId) return; + async function loadParentEvent(eventIdOrRef: string) { + if (!eventIdOrRef || loadingParent || lastLoadAttemptId === eventIdOrRef) return; - lastLoadAttemptId = eventId; + lastLoadAttemptId = eventIdOrRef; loadingParent = true; try { const relays = relayManager.getFeedReadRelays(); - const events = await nostrClient.fetchEvents( - [{ kinds: [KIND.SHORT_TEXT_NOTE], ids: [eventId] }], - relays, - { useCache: true, cacheResults: true } - ); + let events: NostrEvent[] = []; + + // Handle different tag types + if (parentEventTagType === 'a' || parentEventTagType === 'A') { + // Parameterized replaceable event: format is "kind:pubkey:d-tag" + const parts = eventIdOrRef.split(':'); + if (parts.length === 3) { + const kind = parseInt(parts[0], 10); + const pubkey = parts[1]; + const dTag = parts[2]; + + if (!isNaN(kind) && pubkey && dTag) { + events = await nostrClient.fetchEvents( + [{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], + relays, + { useCache: true, cacheResults: true } + ); + } + } + } else if (parentEventTagType === 'i' || parentEventTagType === 'I') { + // Identifier reference: format is "kind:pubkey:identifier" + const parts = eventIdOrRef.split(':'); + if (parts.length === 3) { + const kind = parseInt(parts[0], 10); + const pubkey = parts[1]; + const identifier = parts[2]; + + if (!isNaN(kind) && pubkey && identifier) { + // For identifier tags, try to fetch by kind, author, and identifier + // The identifier might be in a d-tag or other tag + events = await nostrClient.fetchEvents( + [{ kinds: [kind], authors: [pubkey], '#d': [identifier], limit: 1 }], + relays, + { useCache: true, cacheResults: true } + ); + } + } + } else { + // Regular event ID (e, E, q tags): hex event ID + // Try to fetch by ID across all kinds + events = await nostrClient.fetchEvents( + [{ ids: [eventIdOrRef], limit: 1 }], + relays, + { useCache: true, cacheResults: true } + ); + } // Only update if this is still the event we're trying to load - if (lastLoadAttemptId === eventId && events.length > 0) { + if (lastLoadAttemptId === eventIdOrRef && events.length > 0) { loadedParentEvent = events[0]; if (onParentLoaded && typeof onParentLoaded === 'function') { onParentLoaded(loadedParentEvent); @@ -65,7 +111,7 @@ console.error('Error loading parent event:', error); } finally { // Only clear loading state if this is still the current load - if (lastLoadAttemptId === eventId) { + if (lastLoadAttemptId === eventIdOrRef) { loadingParent = false; } } @@ -85,25 +131,66 @@
- Replying to: - {#if loadingParent} - Loading... - {:else if parentEvent} - {getParentPreview()} - {:else} - Parent event not found +
+ Replying to: + {#if loadingParent} + Loading... + {:else if parentEvent} + {getParentPreview()} + {:else} + Parent event not found + {/if} +
+ {#if parentEvent} +
+ { + goto(getEventLink(parentEvent!)); + }} + /> +
{/if}
diff --git a/src/lib/services/keyboard-shortcuts.ts b/src/lib/services/keyboard-shortcuts.ts new file mode 100644 index 0000000..34c2fde --- /dev/null +++ b/src/lib/services/keyboard-shortcuts.ts @@ -0,0 +1,154 @@ +/** + * Keyboard shortcuts service + * Provides global keyboard shortcuts for the application + */ + +export interface KeyboardShortcut { + key: string; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; + meta?: boolean; + description: string; + category: 'navigation' | 'actions' | 'forms' | 'general'; +} + +export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ + // Navigation + { key: 'j', description: 'Navigate to next post/thread', category: 'navigation' }, + { key: 'k', description: 'Navigate to previous post/thread', category: 'navigation' }, + { key: '/', description: 'Focus search box', category: 'navigation' }, + { key: 'Escape', description: 'Close modals/drawers', category: 'navigation' }, + + // Actions + { key: 'r', description: 'Reply to current post/thread', category: 'actions' }, + { key: 'z', description: 'Zap current post/thread', category: 'actions' }, + { key: 'u', description: 'Upvote current post/thread', category: 'actions' }, + { key: 'd', description: 'Downvote current post/thread', category: 'actions' }, + { key: 'b', description: 'Bookmark current post/thread', category: 'actions' }, + + // Forms + { key: 'Enter', ctrl: true, description: 'Submit reply/form (when in textarea)', category: 'forms' }, + { key: 'Enter', meta: true, description: 'Submit reply/form (Mac: Cmd+Enter)', category: 'forms' }, + + // General + { key: '?', description: 'Show keyboard shortcuts help', category: 'general' }, +]; + +export type ShortcutHandler = (event: KeyboardEvent) => void | boolean; + +class KeyboardShortcutsManager { + private handlers: Map> = new Map(); + private enabled = true; + private currentContext: string | null = null; + + constructor() { + if (typeof window !== 'undefined') { + window.addEventListener('keydown', this.handleKeyDown.bind(this)); + } + } + + private getShortcutKey(event: KeyboardEvent): string { + const parts: string[] = []; + if (event.ctrlKey) parts.push('ctrl'); + if (event.shiftKey) parts.push('shift'); + if (event.altKey) parts.push('alt'); + if (event.metaKey) parts.push('meta'); + parts.push(event.key.toLowerCase()); + return parts.join('+'); + } + + private handleKeyDown(event: KeyboardEvent) { + if (!this.enabled) return; + + // Don't trigger shortcuts when typing in inputs, textareas, or contenteditable + const target = event.target as HTMLElement; + if ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable || + target.closest('[contenteditable="true"]') + ) { + // Allow CTRL+ENTER and CMD+ENTER in textareas + if ( + (event.ctrlKey || event.metaKey) && + event.key === 'Enter' && + target.tagName === 'TEXTAREA' + ) { + // Let the textarea handle it + return; + } + // Allow ? to show help from anywhere + if (event.key === '?' && !event.ctrlKey && !event.metaKey && !event.altKey) { + // Continue to process + } else { + return; + } + } + + const shortcutKey = this.getShortcutKey(event); + const handlers = this.handlers.get(shortcutKey); + + if (handlers && handlers.size > 0) { + for (const handler of handlers) { + const result = handler(event); + if (result === false) { + // Handler explicitly prevented default + event.preventDefault(); + event.stopPropagation(); + return; + } + if (result !== undefined) { + // Handler returned a value, assume it handled the event + event.preventDefault(); + event.stopPropagation(); + return; + } + } + } + } + + /** + * Register a keyboard shortcut handler + * @param shortcut The shortcut key combination (e.g., 'ctrl+k', 'j', 'shift+r') + * @param handler The handler function + * @returns A function to unregister the handler + */ + register(shortcut: string, handler: ShortcutHandler): () => void { + const normalized = shortcut.toLowerCase(); + if (!this.handlers.has(normalized)) { + this.handlers.set(normalized, new Set()); + } + this.handlers.get(normalized)!.add(handler); + + return () => { + const handlers = this.handlers.get(normalized); + if (handlers) { + handlers.delete(handler); + if (handlers.size === 0) { + this.handlers.delete(normalized); + } + } + }; + } + + /** + * Enable or disable keyboard shortcuts globally + */ + setEnabled(enabled: boolean) { + this.enabled = enabled; + } + + /** + * Set the current context (for context-specific shortcuts) + */ + setContext(context: string | null) { + this.currentContext = context; + } + + getContext(): string | null { + return this.currentContext; + } +} + +export const keyboardShortcuts = new KeyboardShortcutsManager(); diff --git a/src/routes/discussions/+page.svelte b/src/routes/discussions/+page.svelte index 41de738..0decbc3 100644 --- a/src/routes/discussions/+page.svelte +++ b/src/routes/discussions/+page.svelte @@ -29,8 +29,8 @@ } } - onMount(async () => { - await nostrClient.initialize(); + onMount(() => { + nostrClient.initialize().catch(console.error); }); @@ -129,17 +129,10 @@ .discussions-header-sticky { padding: 0 1rem; - position: sticky; - top: 0; - background: var(--fog-bg, #ffffff); - background-color: var(--fog-bg, #ffffff); - z-index: 10; padding-top: 1rem; padding-bottom: 1rem; margin-bottom: 1rem; border-bottom: 1px solid var(--fog-border, #e5e7eb); - backdrop-filter: none; - opacity: 1; } :global(.dark) .discussions-header-sticky { diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 991a6b1..82d3272 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -5,6 +5,7 @@ import { shouldIncludeClientTag, setIncludeClientTag } from '../../lib/services/client-tag-preference.js'; import { goto } from '$app/navigation'; import Icon from '../../lib/components/ui/Icon.svelte'; + import { KEYBOARD_SHORTCUTS } from '../../lib/services/keyboard-shortcuts.js'; type TextSize = 'small' | 'medium' | 'large'; type LineSpacing = 'tight' | 'normal' | 'loose'; @@ -287,6 +288,45 @@ When enabled, published events will include a client tag (NIP-89) identifying aitherboard as the source.

+ + +
+
+ Keyboard Shortcuts +
+
+ {#each ['navigation', 'actions', 'forms', 'general'] as category} + {@const shortcuts = KEYBOARD_SHORTCUTS.filter(s => s.category === category)} + {#if shortcuts.length > 0} +
+

{category.charAt(0).toUpperCase() + category.slice(1)}

+
+ {#each shortcuts as shortcut} +
+
+ {#if shortcut.ctrl} + Ctrl+ + {/if} + {#if shortcut.meta} + Cmd+ + {/if} + {#if shortcut.shift} + Shift+ + {/if} + {#if shortcut.alt} + Alt+ + {/if} + {shortcut.key === ' ' ? 'Space' : shortcut.key.toUpperCase()} +
+
{shortcut.description}
+
+ {/each} +
+
+ {/if} + {/each} +
+
@@ -427,4 +467,86 @@ color: white; border-color: var(--fog-dark-accent, #94a3b8); } + + .shortcuts-list { + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .shortcuts-category { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .shortcuts-category-title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--fog-text, #475569); + text-transform: capitalize; + } + + :global(.dark) .shortcuts-category-title { + color: var(--fog-dark-text, #cbd5e1); + } + + .shortcuts-items { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .shortcut-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem; + border-radius: 0.25rem; + } + + .shortcut-item:hover { + background: var(--fog-highlight, #f3f4f6); + } + + :global(.dark) .shortcut-item:hover { + background: var(--fog-dark-highlight, #374151); + } + + .shortcut-keys { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; + min-width: 120px; + } + + .shortcut-keys kbd { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + background: var(--fog-highlight, #f3f4f6); + border: 1px solid var(--fog-border, #e5e7eb); + border-radius: 0.25rem; + color: var(--fog-text, #475569); + font-weight: 600; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + } + + :global(.dark) .shortcut-keys kbd { + background: var(--fog-dark-highlight, #374151); + border-color: var(--fog-dark-border, #475569); + color: var(--fog-dark-text, #cbd5e1); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + } + + .shortcut-description { + color: var(--fog-text, #475569); + font-size: 0.875rem; + } + + :global(.dark) .shortcut-description { + color: var(--fog-dark-text, #cbd5e1); + }