Browse Source

bug-fix

master
Silberengel 1 month ago
parent
commit
53bade1816
  1. 111
      src/lib/components/content/ReplyContext.svelte
  2. 11
      src/lib/components/content/RichTextEditor.svelte
  3. 1
      src/lib/components/write/CreateEventForm.svelte
  4. 1
      src/lib/modules/comments/CommentForm.svelte
  5. 4
      src/lib/modules/discussions/DiscussionList.svelte
  6. 55
      src/lib/modules/feed/FeedPage.svelte
  7. 135
      src/lib/modules/feed/FeedPost.svelte
  8. 154
      src/lib/services/keyboard-shortcuts.ts
  9. 11
      src/routes/discussions/+page.svelte
  10. 122
      src/routes/settings/+page.svelte

111
src/lib/components/content/ReplyContext.svelte

@ -4,16 +4,20 @@
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { stripMarkdown } from '../../services/text-utils.js'; import { stripMarkdown } from '../../services/text-utils.js';
import { KIND } from '../../types/kind-lookup.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 { interface Props {
parentEvent?: NostrEvent; // Optional - if not provided, will load by parentEventId parentEvent?: NostrEvent; // Optional - if not provided, will load by parentEventId
parentEventId?: string; // Optional - used to load parent if not provided 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) targetId?: string; // Optional ID to scroll to (defaults to parent event ID)
onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded
onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer 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<NostrEvent | null>(null); let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false); let loadingParent = $state(false);
@ -31,31 +35,73 @@
return; return;
} }
// Determine which event ID we need to load // Only use parentEventId prop, not the derived parentEvent.id
const eventIdToLoad = parentEventId || parentEvent?.id; // This prevents reactive loops
const eventIdToLoad = parentEventId;
// If no provided parent event and we have an ID, try to load it // 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) { if (eventIdToLoad && !loadedParentEvent && !loadingParent && lastLoadAttemptId !== eventIdToLoad) {
loadParentEvent(eventIdToLoad); loadParentEvent(eventIdToLoad);
} }
}); });
async function loadParentEvent(eventId: string) { async function loadParentEvent(eventIdOrRef: string) {
if (!eventId || loadingParent || lastLoadAttemptId === eventId) return; if (!eventIdOrRef || loadingParent || lastLoadAttemptId === eventIdOrRef) return;
lastLoadAttemptId = eventId; lastLoadAttemptId = eventIdOrRef;
loadingParent = true; loadingParent = true;
try { try {
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents( let events: NostrEvent[] = [];
[{ kinds: [KIND.SHORT_TEXT_NOTE], ids: [eventId] }],
// 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, relays,
{ useCache: true, cacheResults: true } { 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 // 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]; loadedParentEvent = events[0];
if (onParentLoaded && typeof onParentLoaded === 'function') { if (onParentLoaded && typeof onParentLoaded === 'function') {
onParentLoaded(loadedParentEvent); onParentLoaded(loadedParentEvent);
@ -65,7 +111,7 @@
console.error('Error loading parent event:', error); console.error('Error loading parent event:', error);
} finally { } finally {
// Only clear loading state if this is still the current load // Only clear loading state if this is still the current load
if (lastLoadAttemptId === eventId) { if (lastLoadAttemptId === eventIdOrRef) {
loadingParent = false; loadingParent = false;
} }
} }
@ -85,25 +131,66 @@
<div <div
class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light" class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light"
> >
<div class="reply-context-content">
<span class="font-semibold">Replying to:</span> <span class="font-semibold">Replying to:</span>
{#if loadingParent} {#if loadingParent}
<span class="opacity-70">Loading...</span> <span class="opacity-70">Loading...</span>
{:else if parentEvent} {:else if parentEvent}
{getParentPreview()} <span class="reply-preview">{getParentPreview()}</span>
{:else} {:else}
<span class="opacity-70">Parent event not found</span> <span class="opacity-70">Parent event not found</span>
{/if} {/if}
</div> </div>
{#if parentEvent}
<div class="reply-context-actions">
<IconButton
icon="eye"
label="View original post"
size={14}
onclick={() => {
goto(getEventLink(parentEvent!));
}}
/>
</div>
{/if}
</div>
<style> <style>
.reply-context { .reply-context {
border-left: 2px solid var(--fog-accent, #64748b); border-left: 2px solid var(--fog-accent, #64748b);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
} }
:global(.dark) .reply-context { :global(.dark) .reply-context {
border-left-color: var(--fog-dark-accent, #64748b); border-left-color: var(--fog-dark-accent, #64748b);
} }
.reply-context-content {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.reply-preview {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reply-context-actions {
flex-shrink: 0;
display: flex;
align-items: center;
}
:global(.highlight-parent) { :global(.highlight-parent) {
outline: 2px solid var(--fog-accent, #64748b); outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px; outline-offset: 2px;

11
src/lib/components/content/RichTextEditor.svelte

@ -16,6 +16,7 @@
uploadContext?: string; // For logging purposes uploadContext?: string; // For logging purposes
onValueChange?: (value: string) => void; onValueChange?: (value: string) => void;
onFilesUploaded?: (files: Array<{ url: string; imetaTag: string[] }>) => void; onFilesUploaded?: (files: Array<{ url: string; imetaTag: string[] }>) => void;
onPublish?: () => void; // Called when CTRL+ENTER is pressed
} }
let { let {
@ -26,7 +27,8 @@
showToolbar = true, showToolbar = true,
uploadContext = 'RichTextEditor', uploadContext = 'RichTextEditor',
onValueChange, onValueChange,
onFilesUploaded onFilesUploaded,
onPublish
}: Props = $props(); }: Props = $props();
let textareaRef: HTMLTextAreaElement | null = $state(null); let textareaRef: HTMLTextAreaElement | null = $state(null);
@ -156,6 +158,13 @@
onValueChange(e.currentTarget.value); onValueChange(e.currentTarget.value);
} }
}} }}
onkeydown={(e) => {
// CTRL+ENTER or CMD+ENTER to publish
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && onPublish && !disabled) {
e.preventDefault();
onPublish();
}
}}
></textarea> ></textarea>
{#if textareaRef} {#if textareaRef}

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

@ -517,6 +517,7 @@
showToolbar={true} showToolbar={true}
uploadContext="CreateEventForm" uploadContext="CreateEventForm"
onFilesUploaded={handleFilesUploaded} onFilesUploaded={handleFilesUploaded}
onPublish={publish}
/> />
<div class="content-buttons"> <div class="content-buttons">

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

@ -375,6 +375,7 @@
showToolbar={showGifButton} showToolbar={showGifButton}
uploadContext="CommentForm" uploadContext="CommentForm"
onFilesUploaded={handleFilesUploaded} onFilesUploaded={handleFilesUploaded}
onPublish={publish}
/> />
<div class="flex items-center justify-between mt-2 comment-form-actions"> <div class="flex items-center justify-between mt-2 comment-form-actions">

4
src/lib/modules/discussions/DiscussionList.svelte

@ -118,6 +118,10 @@
// Only reload if not already loading // Only reload if not already loading
if (!isLoading) { if (!isLoading) {
// If showOlder changed, also reload from cache to get older threads
if (currentShowOlder !== prevShowOlder) {
loadCachedThreads();
}
loadAllData(); loadAllData();
} }
} }

55
src/lib/modules/feed/FeedPage.svelte

@ -7,6 +7,8 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js'; import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js';
import { getRecentFeedEvents } from '../../services/cache/event-cache.js'; import { getRecentFeedEvents } from '../../services/cache/event-cache.js';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
import { browser } from '$app/environment';
interface Props { interface Props {
singleRelay?: string; singleRelay?: string;
@ -467,6 +469,39 @@
}); });
}, 50); }, 50);
// Register j/k navigation shortcuts
if (browser) {
const unregisterJ = keyboardShortcuts.register('j', (e) => {
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.isContentEditable) {
return; // Don't interfere with typing
}
e.preventDefault();
navigateToNextPost();
return false;
});
const unregisterK = keyboardShortcuts.register('k', (e) => {
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.isContentEditable) {
return; // Don't interfere with typing
}
e.preventDefault();
navigateToPreviousPost();
return false;
});
return () => {
clearTimeout(initTimeout);
isMounted = false;
loadInProgress = false;
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
unregisterJ();
unregisterK();
};
}
return () => { return () => {
clearTimeout(initTimeout); clearTimeout(initTimeout);
isMounted = false; isMounted = false;
@ -478,6 +513,26 @@
}; };
}); });
function navigateToNextPost() {
const posts = Array.from(document.querySelectorAll('[data-post-id]'));
const currentIndex = posts.findIndex(p => p === document.activeElement || p.contains(document.activeElement));
const nextIndex = currentIndex < posts.length - 1 ? currentIndex + 1 : 0;
if (posts[nextIndex]) {
(posts[nextIndex] as HTMLElement).focus();
posts[nextIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
function navigateToPreviousPost() {
const posts = Array.from(document.querySelectorAll('[data-post-id]'));
const currentIndex = posts.findIndex(p => p === document.activeElement || p.contains(document.activeElement));
const prevIndex = currentIndex > 0 ? currentIndex - 1 : posts.length - 1;
if (posts[prevIndex]) {
(posts[prevIndex] as HTMLElement).focus();
posts[prevIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
</script> </script>
<div class="feed-page"> <div class="feed-page">

135
src/lib/modules/feed/FeedPost.svelte

@ -23,6 +23,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte'; import IconButton from '../../components/ui/IconButton.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
interface Props { interface Props {
post: NostrEvent; post: NostrEvent;
@ -50,6 +51,34 @@
// Reply state // Reply state
let showReplyForm = $state(false); let showReplyForm = $state(false);
// Keyboard shortcuts - register when component is focused
let isFocused = $state(false);
onMount(() => {
// Register keyboard shortcuts for this post when it's focused
const unregisterShortcuts = keyboardShortcuts.register('r', (e) => {
if (isFocused && isLoggedIn && !showReplyForm) {
e.preventDefault();
showReplyForm = true;
return false;
}
});
const unregisterZap = keyboardShortcuts.register('z', (e) => {
if (isFocused && isLoggedIn) {
e.preventDefault();
// Trigger zap - we'll need to expose a method or use an event
// For now, we'll need to check if ZapButton is available
return false;
}
});
return () => {
unregisterShortcuts();
unregisterZap();
};
});
// Media kinds that should auto-render media (except on /feed) // Media kinds that should auto-render media (except on /feed)
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY]; const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY];
const isMediaKind = $derived(MEDIA_KINDS.includes(post.kind)); const isMediaKind = $derived(MEDIA_KINDS.includes(post.kind));
@ -457,15 +486,64 @@
} }
function isReply(): boolean { function isReply(): boolean {
return post.tags.some((t) => t[0] === 'e' && t[1] !== post.id); // Check for all event reference tags: e, E, a, A, i, I, q
return post.tags.some((t) => {
const tagName = t[0];
if (tagName === 'e' || tagName === 'E' || tagName === 'q') {
return t[1] && t[1] !== post.id;
}
if (tagName === 'a' || tagName === 'A' || tagName === 'i' || tagName === 'I') {
return t[1] && t[1].includes(':');
}
return false;
});
} }
function getReplyEventId(): string | null { function getReplyEventId(): string | null {
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); // Priority order: e/E (reply), q (quote), a/A (parameterized), i/I (identifier)
if (replyTag) return replyTag[1];
// 1. Check for e/E tags (NIP-10 replies)
const replyTag = post.tags.find((t) => (t[0] === 'e' || t[0] === 'E') && t[3] === 'reply');
if (replyTag && replyTag[1]) return replyTag[1];
const rootId = getRootEventId(); const rootId = getRootEventId();
const eTag = post.tags.find((t) => t[0] === 'e' && t[1] !== rootId && t[1] !== post.id); const eTag = post.tags.find((t) => (t[0] === 'e' || t[0] === 'E') && t[1] && t[1] !== rootId && t[1] !== post.id);
return eTag?.[1] || null; if (eTag && eTag[1]) return eTag[1];
// 2. Check for q tag (quoted event)
const qTag = post.tags.find((t) => t[0] === 'q' && t[1]);
if (qTag && qTag[1]) return qTag[1];
// 3. Check for a/A tags (parameterized replaceable events)
const aTag = post.tags.find((t) => (t[0] === 'a' || t[0] === 'A') && t[1]);
if (aTag && aTag[1]) return aTag[1];
// 4. Check for i/I tags (identifier references)
const iTag = post.tags.find((t) => (t[0] === 'i' || t[0] === 'I') && t[1]);
if (iTag && iTag[1]) return iTag[1];
return null;
}
function getReplyTagType(): 'e' | 'E' | 'a' | 'A' | 'i' | 'I' | 'q' | null {
// Return the tag type for the reply, in priority order
const replyTag = post.tags.find((t) => (t[0] === 'e' || t[0] === 'E') && t[3] === 'reply');
if (replyTag) return replyTag[0] as 'e' | 'E';
const rootId = getRootEventId();
const eTag = post.tags.find((t) => (t[0] === 'e' || t[0] === 'E') && t[1] && t[1] !== rootId && t[1] !== post.id);
if (eTag) return eTag[0] as 'e' | 'E';
const qTag = post.tags.find((t) => t[0] === 'q' && t[1]);
if (qTag) return 'q';
const aTag = post.tags.find((t) => (t[0] === 'a' || t[0] === 'A') && t[1]);
if (aTag) return aTag[0] as 'a' | 'A';
const iTag = post.tags.find((t) => (t[0] === 'i' || t[0] === 'I') && t[1]);
if (iTag) return iTag[0] as 'i' | 'I';
return null;
} }
function getRootEventId(): string | null { function getRootEventId(): string | null {
@ -665,6 +743,24 @@
</script> </script>
<div
tabindex="0"
role="button"
aria-label="Post by {post.pubkey.slice(0, 8)}"
onfocus={() => isFocused = true}
onblur={() => isFocused = false}
onkeydown={(e) => {
// Handle shortcuts when post is focused
if (e.key === 'r' && isLoggedIn && !showReplyForm) {
e.preventDefault();
showReplyForm = true;
} else if (e.key === 'Enter' && !showReplyForm) {
// Enter to view full event
e.preventDefault();
goto(getEventLink(post));
}
}}
>
<article <article
bind:this={cardElement} bind:this={cardElement}
class="Feed-post" class="Feed-post"
@ -679,6 +775,7 @@
<ReplyContext <ReplyContext
parentEvent={providedParentEvent || undefined} parentEvent={providedParentEvent || undefined}
parentEventId={getReplyEventId() || undefined} parentEventId={getReplyEventId() || undefined}
parentEventTagType={getReplyTagType() || undefined}
targetId={providedParentEvent ? `event-${providedParentEvent.id}` : undefined} targetId={providedParentEvent ? `event-${providedParentEvent.id}` : undefined}
/> />
{/if} {/if}
@ -790,6 +887,16 @@
</h2> </h2>
{/if} {/if}
<!-- Show reply-to blurb in feed view -->
{#if isReply()}
<ReplyContext
parentEvent={providedParentEvent || undefined}
parentEventId={getReplyEventId() || undefined}
parentEventTagType={getReplyTagType() || undefined}
targetId={providedParentEvent ? `event-${providedParentEvent.id}` : undefined}
/>
{/if}
<!-- Show referenced event preview in feed view --> <!-- Show referenced event preview in feed view -->
<ReferencedEventPreview event={post} preloadedReferencedEvent={preloadedReferencedEvent} /> <ReferencedEventPreview event={post} preloadedReferencedEvent={preloadedReferencedEvent} />
@ -799,7 +906,7 @@
{/if} {/if}
<div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}> <div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}>
{#if shouldAutoRenderMedia} {#if shouldAutoRenderMedia && (post.content && post.content.trim())}
<MediaAttachments event={post} forceRender={isMediaKind} /> <MediaAttachments event={post} forceRender={isMediaKind} />
{/if} {/if}
<p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap"> <p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap">
@ -935,6 +1042,7 @@
</div> </div>
{/if} {/if}
</article> </article>
</div>
{#if isLoggedIn && showReplyForm} {#if isLoggedIn && showReplyForm}
<div class="reply-form-container mb-4"> <div class="reply-form-container mb-4">
@ -1178,4 +1286,19 @@
background-color: rgba(255, 255, 0, 0.2); background-color: rgba(255, 255, 0, 0.2);
} }
/* Focusable wrapper for keyboard navigation */
div[role="button"] {
outline: none;
cursor: default;
}
div[role="button"]:focus {
outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px;
}
:global(.dark) div[role="button"]:focus {
outline-color: var(--fog-dark-accent, #94a3b8);
}
</style> </style>

154
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<string, Set<ShortcutHandler>> = 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();

11
src/routes/discussions/+page.svelte

@ -29,8 +29,8 @@
} }
} }
onMount(async () => { onMount(() => {
await nostrClient.initialize(); nostrClient.initialize().catch(console.error);
}); });
</script> </script>
@ -129,17 +129,10 @@
.discussions-header-sticky { .discussions-header-sticky {
padding: 0 1rem; 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-top: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb); border-bottom: 1px solid var(--fog-border, #e5e7eb);
backdrop-filter: none;
opacity: 1;
} }
:global(.dark) .discussions-header-sticky { :global(.dark) .discussions-header-sticky {

122
src/routes/settings/+page.svelte

@ -5,6 +5,7 @@
import { shouldIncludeClientTag, setIncludeClientTag } from '../../lib/services/client-tag-preference.js'; import { shouldIncludeClientTag, setIncludeClientTag } from '../../lib/services/client-tag-preference.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Icon from '../../lib/components/ui/Icon.svelte'; import Icon from '../../lib/components/ui/Icon.svelte';
import { KEYBOARD_SHORTCUTS } from '../../lib/services/keyboard-shortcuts.js';
type TextSize = 'small' | 'medium' | 'large'; type TextSize = 'small' | 'medium' | 'large';
type LineSpacing = 'tight' | 'normal' | 'loose'; 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. When enabled, published events will include a client tag (NIP-89) identifying aitherboard as the source.
</p> </p>
</div> </div>
<!-- Keyboard Shortcuts -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3">
<span class="font-semibold text-fog-text dark:text-fog-dark-text">Keyboard Shortcuts</span>
</div>
<div class="shortcuts-list">
{#each ['navigation', 'actions', 'forms', 'general'] as category}
{@const shortcuts = KEYBOARD_SHORTCUTS.filter(s => s.category === category)}
{#if shortcuts.length > 0}
<div class="shortcuts-category">
<h3 class="shortcuts-category-title">{category.charAt(0).toUpperCase() + category.slice(1)}</h3>
<div class="shortcuts-items">
{#each shortcuts as shortcut}
<div class="shortcut-item">
<div class="shortcut-keys">
{#if shortcut.ctrl}
<kbd>Ctrl</kbd>+
{/if}
{#if shortcut.meta}
<kbd>Cmd</kbd>+
{/if}
{#if shortcut.shift}
<kbd>Shift</kbd>+
{/if}
{#if shortcut.alt}
<kbd>Alt</kbd>+
{/if}
<kbd>{shortcut.key === ' ' ? 'Space' : shortcut.key.toUpperCase()}</kbd>
</div>
<div class="shortcut-description">{shortcut.description}</div>
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
@ -427,4 +467,86 @@
color: white; color: white;
border-color: var(--fog-dark-accent, #94a3b8); 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);
}
</style> </style>

Loading…
Cancel
Save