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);
+ }