Browse Source

bug fixes and style changes

master
Silberengel 1 month ago
parent
commit
a75b735de7
  1. 4
      public/healthz.json
  2. 4
      src/app.css
  3. 14
      src/lib/components/EventMenu.svelte
  4. 20
      src/lib/components/content/HighlightOverlay.svelte
  5. 286
      src/lib/components/content/RichTextEditor.svelte
  6. 5
      src/lib/components/layout/Header.svelte
  7. 279
      src/lib/components/layout/PubkeyFilter.svelte
  8. 30
      src/lib/components/layout/SearchBox.svelte
  9. 938
      src/lib/components/layout/UnifiedSearch.svelte
  10. 18
      src/lib/components/profile/BookmarksPanel.svelte
  11. 246
      src/lib/components/write/CreateEventForm.svelte
  12. 47
      src/lib/modules/comments/Comment.svelte
  13. 218
      src/lib/modules/comments/CommentForm.svelte
  14. 8
      src/lib/modules/comments/CommentThread.svelte
  15. 65
      src/lib/modules/discussions/DiscussionCard.svelte
  16. 117
      src/lib/modules/discussions/DiscussionList.svelte
  17. 21
      src/lib/modules/events/EventView.svelte
  18. 85
      src/lib/modules/feed/FeedPage.svelte
  19. 100
      src/lib/modules/feed/FeedPost.svelte
  20. 2
      src/lib/modules/feed/HighlightCard.svelte
  21. 2
      src/lib/modules/feed/Reply.svelte
  22. 561
      src/lib/modules/feed/ThreadDrawer.svelte
  23. 2
      src/lib/modules/feed/ZapReceiptReply.svelte
  24. 2
      src/lib/types/kind-lookup.ts
  25. 21
      src/routes/+layout.svelte
  26. 419
      src/routes/bookmarks/+page.svelte
  27. 50
      src/routes/discussions/+page.svelte
  28. 29
      src/routes/feed/+page.svelte
  29. 4
      src/routes/feed/relay/[relay]/+page.svelte
  30. 450
      src/routes/find/+page.svelte
  31. 22
      src/routes/replaceable/[d_tag]/+page.svelte
  32. 29
      src/routes/repos/+page.svelte
  33. 25
      src/routes/repos/[naddr]/+page.svelte
  34. 16
      src/routes/settings/+page.svelte
  35. 70
      src/routes/topics/+page.svelte
  36. 34
      src/routes/topics/[name]/+page.svelte

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.1",
"buildTime": "2026-02-05T23:07:17.455Z",
"buildTime": "2026-02-06T07:27:23.473Z",
"gitCommit": "unknown",
"timestamp": 1770332837455
"timestamp": 1770362843473
}

4
src/app.css

@ -286,8 +286,8 @@ main { @@ -286,8 +286,8 @@ main {
}
.emoji-grayscale {
filter: grayscale(100%);
opacity: 0.7;
filter: grayscale(100%) brightness(1.3);
opacity: 0.5;
}
/* Common button styles */

14
src/lib/components/EventMenu.svelte

@ -23,9 +23,10 @@ @@ -23,9 +23,10 @@
interface Props {
event: NostrEvent;
showContentActions?: boolean; // Show pin/bookmark/highlight for notes with content
onReply?: () => void; // Callback for reply action
}
let { event, showContentActions = false }: Props = $props();
let { event, showContentActions = false, onReply }: Props = $props();
let menuOpen = $state(false);
let jsonModalOpen = $state(false);
@ -43,8 +44,7 @@ @@ -43,8 +44,7 @@
// Unique ID for this menu instance
let menuId = $derived(event.id);
// Check if this is a note with content (kind 1 or kind 11)
let isContentNote = $derived(event.kind === KIND.SHORT_TEXT_NOTE || event.kind === KIND.DISCUSSION_THREAD);
// Note: Removed isContentNote check - all events should have the same menu (except profile pages/cards)
// Check if user is logged in
let isLoggedIn = $derived(sessionManager.isLoggedIn());
@ -413,7 +413,13 @@ @@ -413,7 +413,13 @@
{/if}
</button>
{#if isLoggedIn && showContentActions && isContentNote}
{#if isLoggedIn && onReply}
<div class="menu-divider"></div>
<button class="menu-item" onclick={() => { onReply(); closeMenu(); }}>
Reply
</button>
{/if}
{#if isLoggedIn && showContentActions}
<div class="menu-divider"></div>
<button class="menu-item" onclick={pinNote} class:active={pinnedState}>
Pin note

20
src/lib/components/content/HighlightOverlay.svelte

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
<script lang="ts">
import ProfileBadge from '../layout/ProfileBadge.svelte';
import ThreadDrawer from '../../modules/feed/ThreadDrawer.svelte';
import type { Highlight } from '../../services/nostr/highlight-service.js';
import type { NostrEvent } from '../../types/nostr.js';
import { goto } from '$app/navigation';
interface Props {
highlights: Array<{ start: number; end: number; highlight: Highlight }>;
@ -14,19 +14,11 @@ @@ -14,19 +14,11 @@
let { highlights, content, event, children }: Props = $props();
let containerRef = $state<HTMLElement | null>(null);
let drawerOpen = $state(false);
let selectedHighlight = $state<Highlight | null>(null);
let hoveredHighlight = $state<Highlight | null>(null);
let tooltipPosition = $state({ top: 0, left: 0 });
function openHighlight(highlight: Highlight) {
selectedHighlight = highlight;
drawerOpen = true;
}
function closeDrawer() {
drawerOpen = false;
selectedHighlight = null;
goto(`/event/${highlight.event.id}`);
}
// Apply highlights to rendered HTML content
@ -119,14 +111,6 @@ @@ -119,14 +111,6 @@
{/if}
</div>
{#if drawerOpen && selectedHighlight}
<ThreadDrawer
opEvent={selectedHighlight.event}
isOpen={drawerOpen}
onClose={closeDrawer}
/>
{/if}
<style>
:global(.highlight-span) {
background: rgba(255, 255, 0, 0.3);

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

@ -0,0 +1,286 @@ @@ -0,0 +1,286 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { uploadFileToServer, buildImetaTag } from '../../services/nostr/file-upload.js';
import { insertTextAtCursor } from '../../services/text-utils.js';
import MentionsAutocomplete from './MentionsAutocomplete.svelte';
import GifPicker from './GifPicker.svelte';
import EmojiPicker from './EmojiPicker.svelte';
interface Props {
value: string;
placeholder?: string;
rows?: number;
disabled?: boolean;
showToolbar?: boolean;
uploadContext?: string; // For logging purposes
onValueChange?: (value: string) => void;
onFilesUploaded?: (files: Array<{ url: string; imetaTag: string[] }>) => void;
}
let {
value = $bindable(''),
placeholder = 'Enter text...',
rows = 4,
disabled = false,
showToolbar = true,
uploadContext = 'RichTextEditor',
onValueChange,
onFilesUploaded
}: Props = $props();
let textareaRef: HTMLTextAreaElement | null = $state(null);
let fileInputRef: HTMLInputElement | null = $state(null);
let showGifPicker = $state(false);
let showEmojiPicker = $state(false);
let uploading = $state(false);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
// Generate unique ID for file input
const fileInputId = `rich-text-file-upload-${Math.random().toString(36).substring(7)}`;
// Sync uploaded files to parent
$effect(() => {
if (uploadedFiles.length > 0 && onFilesUploaded) {
onFilesUploaded(uploadedFiles);
}
});
function handleGifSelect(gifUrl: string) {
if (!textareaRef) return;
insertTextAtCursor(textareaRef, gifUrl);
showGifPicker = false;
}
function handleEmojiSelect(emoji: string) {
if (!textareaRef) return;
insertTextAtCursor(textareaRef, emoji);
showEmojiPicker = false;
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files || files.length === 0) return;
// Filter valid file types
const validFiles: File[] = [];
for (const file of Array.from(files)) {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isAudio = file.type.startsWith('audio/');
if (!isImage && !isVideo && !isAudio) {
alert(`${file.name} is not an image, video, or audio file`);
continue;
}
validFiles.push(file);
}
if (validFiles.length === 0) {
return;
}
if (!sessionManager.isLoggedIn()) {
alert('Please log in to upload files');
return;
}
uploading = true;
const uploadPromises: Promise<void>[] = [];
// Process all files
for (const file of validFiles) {
const uploadPromise = (async () => {
try {
// Upload file to media server
const uploadResult = await uploadFileToServer(file, uploadContext);
console.log(`[${uploadContext}] Uploaded ${file.name} to ${uploadResult.url}`);
// Build imeta tag from upload response (NIP-92 format)
const imetaTag = buildImetaTag(file, uploadResult);
// Store file with imeta tag
uploadedFiles.push({
url: uploadResult.url,
imetaTag
});
// Insert file URL into textarea (plain URL for all file types)
if (textareaRef) {
insertTextAtCursor(textareaRef, `${uploadResult.url}\n`);
}
} catch (error) {
console.error(`[${uploadContext}] File upload failed for ${file.name}:`, error);
const errorMessage = error instanceof Error ? error.message : String(error);
alert(`Failed to upload ${file.name}: ${errorMessage}`);
}
})();
uploadPromises.push(uploadPromise);
}
// Wait for all uploads to complete
try {
await Promise.all(uploadPromises);
} finally {
uploading = false;
// Reset file input
if (fileInputRef) {
fileInputRef.value = '';
}
}
}
// Expose method to clear uploaded files (useful when form is cleared)
export function clearUploadedFiles() {
uploadedFiles = [];
}
// Expose method to get uploaded files
export function getUploadedFiles() {
return uploadedFiles;
}
</script>
<div class="textarea-wrapper">
<textarea
bind:this={textareaRef}
bind:value
{placeholder}
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text {showToolbar ? 'has-buttons' : ''}"
{rows}
{disabled}
oninput={(e) => {
if (onValueChange) {
onValueChange(e.currentTarget.value);
}
}}
></textarea>
{#if textareaRef}
<MentionsAutocomplete textarea={textareaRef} />
{/if}
{#if showToolbar}
<div class="textarea-buttons">
<button
type="button"
onclick={() => {
showGifPicker = !showGifPicker;
showEmojiPicker = false;
}}
class="toolbar-button"
title="Insert GIF"
aria-label="Insert GIF"
{disabled}
>
GIF
</button>
<button
type="button"
onclick={() => { showEmojiPicker = !showEmojiPicker; showGifPicker = false; }}
class="toolbar-button"
title="Insert emoji"
aria-label="Insert emoji"
{disabled}
>
😀
</button>
<input
type="file"
bind:this={fileInputRef}
accept="image/*,video/*,audio/*"
multiple
onchange={handleFileUpload}
class="hidden"
id={fileInputId}
disabled={disabled || uploading}
/>
<label
for={fileInputId}
class="toolbar-button upload-button"
title="Upload file (image, video, or audio)"
aria-label="Upload file"
>
📤
</label>
</div>
{/if}
</div>
<GifPicker open={showGifPicker} onSelect={handleGifSelect} onClose={() => showGifPicker = false} />
<EmojiPicker open={showEmojiPicker} onSelect={handleEmojiSelect} onClose={() => showEmojiPicker = false} />
<style>
.textarea-wrapper {
position: relative;
}
textarea {
resize: vertical;
min-height: 100px;
}
/* Add padding to bottom when buttons are visible to prevent text overlap */
textarea.has-buttons {
padding-bottom: 3rem; /* Increased padding to accommodate buttons */
}
textarea:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
}
.textarea-buttons {
position: absolute;
bottom: 0.5rem;
left: 0.75rem;
display: flex;
gap: 0.25rem;
z-index: 10;
padding-top: 0.5rem; /* Add padding above buttons to separate from text */
}
.upload-button {
cursor: pointer;
user-select: none;
}
.toolbar-button {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text-light, #6b7280);
cursor: pointer;
transition: all 0.2s;
}
.toolbar-button:hover:not(:disabled) {
background: var(--fog-border, #e5e7eb);
border-color: var(--fog-accent, #64748b);
color: var(--fog-text, #475569);
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .toolbar-button {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text-light, #9ca3af);
}
:global(.dark) .toolbar-button:hover:not(:disabled) {
background: var(--fog-dark-border, #475569);
border-color: var(--fog-dark-accent, #64748b);
color: var(--fog-dark-text, #cbd5e1);
}
.hidden {
display: none;
}
</style>

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

@ -63,8 +63,8 @@ @@ -63,8 +63,8 @@
<a href="/relay" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Relay</a>
<a href="/topics" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Topics</a>
<a href="/repos" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Repos</a>
<a href="/cache" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Cache</a>
<a href="/bookmarks" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Bookmarks</a>
<a href="/cache" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Cache</a>
<a href="/settings" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Settings</a>
{#if isLoggedIn && currentPubkey}
<a href="/logout" onclick={(e) => { e.preventDefault(); handleLogout(); }} class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Logout</a>
@ -109,6 +109,9 @@ @@ -109,6 +109,9 @@
nav {
min-width: 0; /* Allow flex items to shrink */
position: sticky;
top: 0;
z-index: 100;
}
/* Responsive navigation links */

279
src/lib/components/layout/PubkeyFilter.svelte

@ -0,0 +1,279 @@ @@ -0,0 +1,279 @@
<script lang="ts">
import { nip19 } from 'nostr-tools';
import { getProfile, getProfiles } from '../../services/cache/profile-cache.js';
import { parseProfile } from '../../services/user-data.js';
interface Props {
value: string;
onInput: (value: string) => void;
placeholder?: string;
}
let { value, onInput, placeholder = 'Search by pubkey, p, q tags, or content...' }: Props = $props();
// Resolved pubkey from NIP-05 or other formats
let resolvedPubkey = $state<string | null>(null);
let resolving = $state(false);
function handleInput(e: Event) {
const input = e.target as HTMLInputElement;
onInput(input.value);
// Trigger async resolution
resolvePubkey(input.value);
}
// Check if input looks like NIP-05 (user@domain.com)
function isNIP05(input: string): boolean {
const trimmed = input.trim();
// Simple check: contains @ and has at least one character before and after
return /^[^@]+@[^@]+\.[^@]+$/.test(trimmed);
}
// Search cache for profiles with matching NIP-05
async function searchCacheForNIP05(nip05: string): Promise<string | null> {
try {
// Get all profiles from cache (we need to iterate through them)
// Since we don't have an index on nip05, we'll need to get all profiles
// This is not ideal but necessary for now
const db = await import('../../services/cache/indexeddb-store.js').then(m => m.getDB());
const tx = db.transaction('profiles', 'readonly');
const store = tx.store;
const profiles: any[] = [];
// Get all profiles (this could be slow with many profiles, but it's cached)
let cursor = await store.openCursor();
while (cursor) {
profiles.push(cursor.value);
cursor = await cursor.continue();
}
await tx.done;
// Search for matching NIP-05
const normalizedNIP05 = nip05.toLowerCase();
for (const cached of profiles) {
const profile = parseProfile(cached.event);
if (profile.nip05) {
for (const profileNip05 of profile.nip05) {
if (profileNip05.toLowerCase() === normalizedNIP05) {
return cached.pubkey;
}
}
}
}
} catch (error) {
console.debug('Error searching cache for NIP-05:', error);
}
return null;
}
// Resolve NIP-05 from well-known.json
async function resolveNIP05FromWellKnown(nip05: string): Promise<string | null> {
try {
const [localPart, domain] = nip05.split('@');
if (!localPart || !domain) return null;
const wellKnownUrl = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout
try {
const response = await fetch(wellKnownUrl, { signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
return null;
}
const data = await response.json();
const names = data.names || {};
const pubkey = names[localPart];
if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) {
return pubkey.toLowerCase();
}
} catch (fetchError) {
clearTimeout(timeout);
if (fetchError instanceof Error && fetchError.name !== 'AbortError') {
console.debug('Error fetching well-known.json:', fetchError);
}
}
} catch (error) {
console.debug('Error resolving NIP-05:', error);
}
return null;
}
// Helper to normalize input to hex pubkey (sync for hex/npub/nprofile, async for NIP-05)
function normalizeToHexSync(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
// If it's already a hex pubkey (64 hex chars)
if (/^[a-fA-F0-9]{64}$/.test(trimmed)) {
return trimmed.toLowerCase();
}
// Try to decode as bech32 (npub or nprofile)
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') {
return decoded.data as string;
} else if (decoded.type === 'nprofile') {
return (decoded.data as any).pubkey;
}
} catch {
// Not a valid bech32
}
return null;
}
// Async resolution for NIP-05
async function resolvePubkey(input: string) {
const trimmed = input.trim();
if (!trimmed) {
resolvedPubkey = null;
return;
}
// Try sync resolution first
const syncResult = normalizeToHexSync(trimmed);
if (syncResult) {
resolvedPubkey = syncResult;
return;
}
// If it looks like NIP-05, resolve it
if (isNIP05(trimmed)) {
resolving = true;
try {
// First check cache
const cachedPubkey = await searchCacheForNIP05(trimmed);
if (cachedPubkey) {
resolvedPubkey = cachedPubkey;
resolving = false;
return;
}
// Then check well-known.json
const wellKnownPubkey = await resolveNIP05FromWellKnown(trimmed);
if (wellKnownPubkey) {
resolvedPubkey = wellKnownPubkey;
} else {
resolvedPubkey = null;
}
} catch (error) {
console.debug('Error resolving NIP-05:', error);
resolvedPubkey = null;
} finally {
resolving = false;
}
} else {
resolvedPubkey = null;
}
}
// Resolve when value changes
let resolveTimeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
// Debounce resolution to avoid too many requests
if (resolveTimeout) clearTimeout(resolveTimeout);
resolveTimeout = setTimeout(() => {
resolvePubkey(value);
}, 300); // 300ms debounce
return () => {
if (resolveTimeout) clearTimeout(resolveTimeout);
};
});
// Expose resolved pubkey for parent components
export function getNormalizedPubkey(): string | null {
return resolvedPubkey;
}
// Expose resolving state
export function isResolving(): boolean {
return resolving;
}
</script>
<div class="pubkey-filter">
<input
type="text"
value={value}
oninput={handleInput}
placeholder={placeholder}
class="filter-input"
class:resolving={resolving}
/>
{#if resolving}
<span class="resolving-indicator" title="Resolving NIP-05..."></span>
{/if}
</div>
<style>
.pubkey-filter {
margin-bottom: 1rem;
}
.filter-input {
width: 100%;
max-width: 500px;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: monospace;
}
:global(.dark) .filter-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.filter-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .filter-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.filter-input.resolving {
border-color: var(--fog-accent, #64748b);
}
.pubkey-filter {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.resolving-indicator {
color: var(--fog-accent, #64748b);
font-size: 1rem;
animation: spin 1s linear infinite;
}
:global(.dark) .resolving-indicator {
color: var(--fog-dark-accent, #94a3b8);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

30
src/lib/components/layout/SearchBox.svelte

@ -64,6 +64,9 @@ @@ -64,6 +64,9 @@
return;
}
// Ensure nostrClient is initialized
await nostrClient.initialize();
searching = true;
searchResults = [];
showResults = true;
@ -76,7 +79,7 @@ @@ -76,7 +79,7 @@
if (decoded.type === 'event' && decoded.id) {
// Search for specific event ID
let event = await getEvent(decoded.id);
let event: NostrEvent | undefined = await getEvent(decoded.id);
if (!event) {
// Not in cache, fetch from relays
@ -116,14 +119,21 @@ @@ -116,14 +119,21 @@
const kind11Events = await getEventsByKind(KIND.DISCUSSION_THREAD, 100);
allCached.push(...kind11Events);
// Filter by search query
// Filter by search query - search title, summary, and content
const queryLower = query.toLowerCase();
const matches = allCached.filter(event => {
// Search content
const contentMatch = event.content.toLowerCase().includes(queryLower);
const tagMatch = event.tags.some(tag =>
tag.some(val => val && val.toLowerCase().includes(queryLower))
);
return contentMatch || tagMatch;
// Search title tag
const titleTag = event.tags.find(t => t[0] === 'title');
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false;
// Search summary tag
const summaryTag = event.tags.find(t => t[0] === 'summary');
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false;
return contentMatch || titleMatch || summaryMatch;
});
// Sort by relevance (exact matches first, then by created_at)
@ -146,7 +156,8 @@ @@ -146,7 +156,8 @@
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
searchQuery = target.value;
const newValue = target.value;
searchQuery = newValue;
// Clear existing timeout
if (searchTimeout) {
@ -154,13 +165,16 @@ @@ -154,13 +165,16 @@
}
// Debounce search - wait 300ms after user stops typing
if (searchQuery.trim()) {
if (newValue.trim()) {
// Show loading indicator immediately
searching = true;
searchTimeout = setTimeout(() => {
performSearch();
}, 300);
} else {
searchResults = [];
showResults = false;
searching = false;
}
}

938
src/lib/components/layout/UnifiedSearch.svelte

@ -0,0 +1,938 @@ @@ -0,0 +1,938 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { getEvent, getEventsByKind } from '../../services/cache/event-cache.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import { KIND, KIND_LOOKUP } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
import { parseProfile } from '../../services/user-data.js';
interface Props {
mode?: 'search' | 'filter'; // 'search' shows dropdown, 'filter' filters page content
placeholder?: string;
onFilterChange?: (result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }) => void;
showKindFilter?: boolean; // Show kind filter dropdown
selectedKind?: number | null; // Selected kind for filtering
onKindChange?: (kind: number | null) => void; // Callback when kind filter changes
hideDropdownResults?: boolean; // If true, don't show dropdown results (for /find page)
onSearchResults?: (results: { events: NostrEvent[]; profiles: string[] }) => void; // Callback for search results (events and profile pubkeys)
}
let { mode = 'search', placeholder = 'Search events, profiles, pubkeys, or enter event ID...', onFilterChange, showKindFilter = false, selectedKind = null, onKindChange, hideDropdownResults = false, onSearchResults }: Props = $props();
let searchQuery = $state('');
let searching = $state(false);
let resolving = $state(false);
let searchResults = $state<Array<{ event: NostrEvent; matchType: string }>>([]);
let showResults = $state(false);
let searchInput: HTMLInputElement | null = $state(null);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// For collecting results when hideDropdownResults is true
let foundEvents: NostrEvent[] = [];
let foundProfiles: string[] = [];
// For filter mode: resolved search result
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }>({ type: null, value: null, kind: null });
// Note: filterResult kind is updated in performSearch, not here to avoid loops
// Check if input looks like NIP-05 (user@domain.com)
function isNIP05(input: string): boolean {
const trimmed = input.trim();
return /^[^@]+@[^@]+\.[^@]+$/.test(trimmed);
}
// Search cache for profiles with matching NIP-05
async function searchCacheForNIP05(nip05: string): Promise<string | null> {
try {
const db = await import('../../services/cache/indexeddb-store.js').then(m => m.getDB());
const tx = db.transaction('profiles', 'readonly');
const store = tx.store;
const profiles: any[] = [];
let cursor = await store.openCursor();
while (cursor) {
profiles.push(cursor.value);
cursor = await cursor.continue();
}
await tx.done;
const normalizedNIP05 = nip05.toLowerCase();
for (const cached of profiles) {
const profile = parseProfile(cached.event);
if (profile.nip05) {
for (const profileNip05 of profile.nip05) {
if (profileNip05.toLowerCase() === normalizedNIP05) {
return cached.pubkey;
}
}
}
}
} catch (error) {
console.debug('Error searching cache for NIP-05:', error);
}
return null;
}
// Resolve NIP-05 from well-known.json
async function resolveNIP05FromWellKnown(nip05: string): Promise<string | null> {
try {
const [localPart, domain] = nip05.split('@');
if (!localPart || !domain) return null;
const wellKnownUrl = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(wellKnownUrl, { signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
return null;
}
const data = await response.json();
const names = data.names || {};
const pubkey = names[localPart];
if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) {
return pubkey.toLowerCase();
}
} catch (fetchError) {
clearTimeout(timeout);
if (fetchError instanceof Error && fetchError.name !== 'AbortError') {
console.debug('Error fetching well-known.json:', fetchError);
}
}
} catch (error) {
console.debug('Error resolving NIP-05:', error);
}
return null;
}
async function performSearch() {
if (!searchQuery.trim()) {
searchResults = [];
showResults = false;
filterResult = { type: null, value: null, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
return;
}
await nostrClient.initialize();
searching = true;
resolving = true;
searchResults = [];
showResults = true;
filterResult = { type: null, value: null, kind: selectedKind };
try {
const query = searchQuery.trim();
// 1. Check if it's a hex event ID (64 hex chars)
if (/^[0-9a-f]{64}$/i.test(query)) {
const hexId = query.toLowerCase();
let event: NostrEvent | undefined = await getEvent(hexId);
if (!event) {
const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [hexId] }],
relays,
{ useCache: false, cacheResults: true }
);
if (events.length > 0) {
event = events[0];
await cacheEvent(event);
}
}
if (event) {
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
onSearchResults({ events: foundEvents, profiles: [] });
} else {
searchResults = [{ event, matchType: 'Event ID' }];
showResults = true;
}
} else {
filterResult = { type: 'event', value: event.id };
if (onFilterChange) onFilterChange(filterResult);
}
searching = false;
resolving = false;
return;
}
// Event not found, try as pubkey (step 2)
const hexPubkey = hexId.toLowerCase();
// If kind is selected, search for events with this pubkey in p/q tags
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getProfileReadRelays();
const filters: any[] = [{
kinds: [selectedKind],
limit: 100
}];
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#p': [hexPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#q': [hexPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Also search by author
const eventsByAuthor = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, authors: [hexPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Combine and deduplicate
const allEvents = new Map<string, NostrEvent>();
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) {
allEvents.set(event.id, event);
}
foundEvents = Array.from(allEvents.values());
onSearchResults({ events: foundEvents, profiles: [] });
searching = false;
resolving = false;
return;
} else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundProfiles = [hexPubkey];
onSearchResults({ events: [], profiles: foundProfiles });
} else {
// For search mode, navigate to profile
handleProfileClick(hexPubkey);
}
searching = false;
resolving = false;
return;
} else {
filterResult = { type: 'pubkey', value: hexPubkey, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
return;
}
}
// 3. Check npub, nprofile (resolve to hex)
if (/^(npub|nprofile)1[a-z0-9]+$/i.test(query)) {
try {
const decoded = nip19.decode(query);
let pubkey: string | null = null;
if (decoded.type === 'npub') {
pubkey = String(decoded.data);
} else if (decoded.type === 'nprofile') {
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
pubkey = String(decoded.data.pubkey);
}
}
if (pubkey) {
const normalizedPubkey = pubkey.toLowerCase();
// If kind is selected, search for events with this pubkey in p/q tags
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getProfileReadRelays();
const filters: any[] = [{
kinds: [selectedKind],
limit: 100
}];
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#p': [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#q': [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Also search by author
const eventsByAuthor = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, authors: [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Combine and deduplicate
const allEvents = new Map<string, NostrEvent>();
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) {
allEvents.set(event.id, event);
}
foundEvents = Array.from(allEvents.values());
onSearchResults({ events: foundEvents, profiles: [] });
searching = false;
resolving = false;
return;
} else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundProfiles = [normalizedPubkey];
onSearchResults({ events: [], profiles: foundProfiles });
} else {
handleProfileClick(normalizedPubkey);
}
searching = false;
resolving = false;
return;
} else {
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
return;
}
}
} catch (error) {
console.debug('Error decoding npub/nprofile:', error);
}
}
// 4. Check NIP-05 (resolve to hex)
if (isNIP05(query)) {
resolving = true;
try {
// First check cache
const cachedPubkey = await searchCacheForNIP05(query);
if (cachedPubkey) {
const normalizedPubkey = cachedPubkey.toLowerCase();
// If kind is selected, search for events with this pubkey in p/q tags
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getProfileReadRelays();
const filters: any[] = [{
kinds: [selectedKind],
limit: 100
}];
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#p': [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#q': [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Also search by author
const eventsByAuthor = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, authors: [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Combine and deduplicate
const allEvents = new Map<string, NostrEvent>();
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) {
allEvents.set(event.id, event);
}
foundEvents = Array.from(allEvents.values());
onSearchResults({ events: foundEvents, profiles: [] });
searching = false;
resolving = false;
return;
} else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundProfiles = [normalizedPubkey];
onSearchResults({ events: [], profiles: foundProfiles });
} else {
handleProfileClick(normalizedPubkey);
}
searching = false;
resolving = false;
return;
} else {
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
return;
}
}
// Then check well-known.json
const wellKnownPubkey = await resolveNIP05FromWellKnown(query);
if (wellKnownPubkey) {
const normalizedPubkey = wellKnownPubkey.toLowerCase();
// If kind is selected, search for events with this pubkey in p/q tags
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getProfileReadRelays();
const filters: any[] = [{
kinds: [selectedKind],
limit: 100
}];
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#p': [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#q': [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Also search by author
const eventsByAuthor = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, authors: [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Combine and deduplicate
const allEvents = new Map<string, NostrEvent>();
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) {
allEvents.set(event.id, event);
}
foundEvents = Array.from(allEvents.values());
onSearchResults({ events: foundEvents, profiles: [] });
searching = false;
resolving = false;
return;
} else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundProfiles = [normalizedPubkey];
onSearchResults({ events: [], profiles: foundProfiles });
} else {
handleProfileClick(normalizedPubkey);
}
searching = false;
resolving = false;
return;
} else {
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
return;
}
}
} catch (error) {
console.debug('Error resolving NIP-05:', error);
}
resolving = false;
}
// 5. Check note, nevent, naddr (resolve to hex event ID)
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(query)) {
try {
const decoded = nip19.decode(query);
let eventId: string | null = null;
if (decoded.type === 'note') {
eventId = String(decoded.data);
} else if (decoded.type === 'nevent') {
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
eventId = String(decoded.data.id);
}
} else if (decoded.type === 'naddr') {
// naddr is more complex, would need kind+pubkey+d to fetch
// For now, we'll skip it and treat as text search
}
if (eventId) {
let event: NostrEvent | undefined = await getEvent(eventId);
if (!event) {
const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [eventId] }],
relays,
{ useCache: false, cacheResults: true }
);
if (events.length > 0) {
event = events[0];
await cacheEvent(event);
}
}
if (event) {
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
onSearchResults({ events: foundEvents, profiles: [] });
} else {
searchResults = [{ event, matchType: 'Event ID' }];
showResults = true;
}
} else {
filterResult = { type: 'event', value: event.id, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
}
searching = false;
resolving = false;
return;
}
}
} catch (error) {
console.debug('Error decoding note/nevent/naddr:', error);
}
}
// 6. Anything else is a full-text search
if (mode === 'search') {
// Text search in cached events (title, summary, content)
const allCached: NostrEvent[] = [];
// If kind filter is selected, only search that kind
if (selectedKind !== null) {
const kindEvents = await getEventsByKind(selectedKind, 100);
allCached.push(...kindEvents);
} else {
// Search all kinds we handle
const kindsToSearch = Object.keys(KIND_LOOKUP).map(k => parseInt(k)).filter(k => !KIND_LOOKUP[k].isSecondaryKind);
for (const kind of kindsToSearch) {
try {
const kindEvents = await getEventsByKind(kind, 50);
allCached.push(...kindEvents);
} catch (e) {
// Skip kinds that fail
}
}
}
const queryLower = query.toLowerCase();
const matches = allCached.filter(event => {
const contentMatch = event.content.toLowerCase().includes(queryLower);
const titleTag = event.tags.find(t => t[0] === 'title');
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false;
const summaryTag = event.tags.find(t => t[0] === 'summary');
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false;
return contentMatch || titleMatch || summaryMatch;
});
const sorted = matches.sort((a, b) => {
const aExact = a.content.toLowerCase() === queryLower;
const bExact = b.content.toLowerCase() === queryLower;
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
return b.created_at - a.created_at;
});
const limitedResults = sorted.slice(0, 20);
if (hideDropdownResults && onSearchResults) {
foundEvents = limitedResults;
onSearchResults({ events: foundEvents, profiles: [] });
} else {
searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content' }));
showResults = true;
}
} else {
// Filter mode: treat as text search
filterResult = { type: 'text', value: query, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
}
} catch (error) {
console.error('Search error:', error);
} finally {
searching = false;
resolving = false;
}
}
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
const newValue = target.value;
searchQuery = newValue;
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (newValue.trim()) {
searching = true;
searchTimeout = setTimeout(() => {
performSearch();
}, 300);
} else {
searchResults = [];
showResults = false;
searching = false;
filterResult = { type: null, value: null, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
performSearch();
} else if (e.key === 'Escape') {
showResults = false;
searchQuery = '';
filterResult = { type: null, value: null, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
}
}
function handleResultClick(event: NostrEvent) {
showResults = false;
searchQuery = '';
goto(`/event/${event.id}`);
}
function handleProfileClick(pubkey: string) {
showResults = false;
searchQuery = '';
goto(`/profile/${pubkey}`);
}
// Close results when clicking outside
$effect(() => {
if (showResults) {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.unified-search-container')) {
showResults = false;
}
};
document.addEventListener('click', handleClickOutside, true);
return () => {
document.removeEventListener('click', handleClickOutside, true);
};
}
});
// Cleanup timeout on unmount
$effect(() => {
return () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
};
});
// Expose filter result for parent components (filter mode)
export function getFilterResult(): { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } {
return filterResult;
}
// Expose performSearch for manual trigger
export function triggerSearch() {
performSearch();
}
// Note: filterResult kind is updated in performSearch, not here to avoid loops
// Get all kinds for dropdown (sorted by number)
let allKinds = $derived(Object.values(KIND_LOOKUP).sort((a, b) => a.number - b.number));
function handleKindChange(e: Event) {
const select = e.target as HTMLSelectElement;
const kind = select.value === '' ? null : parseInt(select.value);
if (onKindChange) {
onKindChange(kind);
}
// Update filter result
filterResult = { ...filterResult, kind };
if (onFilterChange) {
onFilterChange(filterResult);
}
// Re-run search if there's a query
if (searchQuery.trim()) {
performSearch();
}
}
</script>
<div class="unified-search-container">
<div class="search-input-wrapper">
{#if showKindFilter}
<select
value={selectedKind?.toString() || ''}
onchange={handleKindChange}
class="kind-filter-select"
aria-label="Filter by kind"
>
<option value="">All Kinds</option>
{#each allKinds as kindInfo}
<option value={kindInfo.number}>{kindInfo.number}: {kindInfo.description}</option>
{/each}
</select>
{/if}
<input
bind:this={searchInput}
type="text"
placeholder={placeholder}
value={searchQuery}
oninput={handleSearchInput}
onkeydown={handleKeyDown}
class="search-input"
class:resolving={resolving}
class:with-kind-filter={showKindFilter}
aria-label="Search"
/>
{#if searching || resolving}
<span class="search-loading"></span>
{/if}
</div>
{#if mode === 'search' && !hideDropdownResults && showResults && searchResults.length > 0}
<div class="search-results">
{#each searchResults as { event, matchType }}
<button
onclick={() => {
if (event.kind === KIND.METADATA) {
handleProfileClick(event.pubkey);
} else {
handleResultClick(event);
}
}}
class="search-result-item"
>
<div class="search-result-header">
<span class="search-result-type">{matchType}</span>
<span class="search-result-id">{event.id.substring(0, 16)}...</span>
</div>
<div class="search-result-content">
{event.content.substring(0, 100)}{event.content.length > 100 ? '...' : ''}
</div>
<div class="search-result-meta">
Kind {event.kind}{new Date(event.created_at * 1000).toLocaleDateString()}
</div>
</button>
{/each}
</div>
{:else if mode === 'search' && !hideDropdownResults && showResults && !searching && searchQuery.trim()}
<div class="search-results">
<div class="search-no-results">No results found</div>
</div>
{/if}
</div>
<style>
.unified-search-container {
position: relative;
width: 100%;
max-width: 600px;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.kind-filter-select {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: monospace;
cursor: pointer;
min-width: 150px;
}
.kind-filter-select:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .kind-filter-select {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .kind-filter-select:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.search-input.with-kind-filter {
flex: 1;
}
.search-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
transition: all 0.2s;
}
.search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
.search-input.resolving {
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .search-input {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .search-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.search-loading {
position: absolute;
right: 1rem;
color: var(--fog-text-light, #9ca3af);
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 400px;
overflow-y: auto;
z-index: 1000;
}
:global(.dark) .search-results {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.search-result-item {
width: 100%;
padding: 0.75rem;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .search-result-item {
border-bottom-color: var(--fog-dark-border, #374151);
}
:global(.dark) .search-result-item:hover {
background: var(--fog-dark-highlight, #374151);
}
.search-result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.search-result-type {
font-size: 0.75rem;
font-weight: 600;
color: var(--fog-accent, #64748b);
text-transform: uppercase;
}
:global(.dark) .search-result-type {
color: var(--fog-dark-accent, #94a3b8);
}
.search-result-id {
font-size: 0.75rem;
font-family: monospace;
color: var(--fog-text-light, #9ca3af);
}
:global(.dark) .search-result-id {
color: var(--fog-dark-text-light, #6b7280);
}
.search-result-content {
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
margin-bottom: 0.25rem;
line-height: 1.4;
}
:global(.dark) .search-result-content {
color: var(--fog-dark-text, #f9fafb);
}
.search-result-meta {
font-size: 0.75rem;
color: var(--fog-text-light, #9ca3af);
}
:global(.dark) .search-result-meta {
color: var(--fog-dark-text-light, #6b7280);
}
.search-no-results {
padding: 1rem;
text-align: center;
color: var(--fog-text-light, #9ca3af);
font-size: 0.875rem;
}
:global(.dark) .search-no-results {
color: var(--fog-dark-text-light, #6b7280);
}
</style>

18
src/lib/components/profile/BookmarksPanel.svelte

@ -4,10 +4,10 @@ @@ -4,10 +4,10 @@
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import FeedPost from '../../modules/feed/FeedPost.svelte';
import ThreadDrawer from '../../modules/feed/ThreadDrawer.svelte';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import { goto } from '$app/navigation';
interface Props {
isOpen: boolean;
@ -18,17 +18,9 @@ @@ -18,17 +18,9 @@
let bookmarkedEvents = $state<NostrEvent[]>([]);
let loading = $state(true);
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
function openDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
}
function closeDrawer() {
drawerOpen = false;
drawerEvent = null;
function navigateToEvent(event: NostrEvent) {
goto(`/event/${event.id}`);
}
$effect(() => {
@ -115,7 +107,7 @@ @@ -115,7 +107,7 @@
{:else}
<div class="bookmarks-list">
{#each bookmarkedEvents as event (event.id)}
<FeedPost post={event} onOpenEvent={openDrawer} />
<FeedPost post={event} onOpenEvent={navigateToEvent} />
{/each}
</div>
{/if}
@ -124,8 +116,6 @@ @@ -124,8 +116,6 @@
</div>
{/if}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
<style>
.bookmarks-panel-overlay {
position: fixed;

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

@ -8,14 +8,11 @@ @@ -8,14 +8,11 @@
import PublicationStatusModal from '../modals/PublicationStatusModal.svelte';
import MarkdownRenderer from '../content/MarkdownRenderer.svelte';
import MediaAttachments from '../content/MediaAttachments.svelte';
import GifPicker from '../content/GifPicker.svelte';
import EmojiPicker from '../content/EmojiPicker.svelte';
import { insertTextAtCursor } from '../../services/text-utils.js';
import RichTextEditor from '../content/RichTextEditor.svelte';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
import { goto } from '$app/navigation';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
import MentionsAutocomplete from '../content/MentionsAutocomplete.svelte';
import { extractMentions, getMentionPubkeys } from '../../services/mentions.js';
const SUPPORTED_KINDS = [
@ -111,11 +108,7 @@ @@ -111,11 +108,7 @@
let showJsonModal = $state(false);
let showPreviewModal = $state(false);
let showExampleModal = $state(false);
let showGifPicker = $state(false);
let showEmojiPicker = $state(false);
let textareaRef: HTMLTextAreaElement | null = $state(null);
let fileInputRef: HTMLInputElement | null = $state(null);
let uploading = $state(false);
let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
let eventJson = $state('{}');
@ -538,92 +531,10 @@ @@ -538,92 +531,10 @@
}
function handleGifSelect(gifUrl: string) {
if (!textareaRef) return;
// Insert GIF URL as plain text
insertTextAtCursor(textareaRef, gifUrl);
showGifPicker = false;
function handleFilesUploaded(files: Array<{ url: string; imetaTag: string[] }>) {
uploadedFiles = files;
}
function handleEmojiSelect(emoji: string) {
if (!textareaRef) return;
insertTextAtCursor(textareaRef, emoji);
showEmojiPicker = false;
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files || files.length === 0) return;
// Filter valid file types
const validFiles: File[] = [];
for (const file of Array.from(files)) {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isAudio = file.type.startsWith('audio/');
if (!isImage && !isVideo && !isAudio) {
alert(`${file.name} is not an image, video, or audio file`);
continue;
}
validFiles.push(file);
}
if (validFiles.length === 0) {
return;
}
if (!sessionManager.isLoggedIn()) {
alert('Please log in to upload files');
return;
}
uploading = true;
const uploadPromises: Promise<void>[] = [];
// Process all files
for (const file of validFiles) {
const uploadPromise = (async () => {
try {
// Upload file to media server
const uploadResult = await uploadFileToServer(file, 'CreateEventForm');
console.log(`[CreateEventForm] Uploaded ${file.name} to ${uploadResult.url}`);
// Build imeta tag from upload response (NIP-92 format)
const imetaTag = buildImetaTag(file, uploadResult);
// Store file with imeta tag
uploadedFiles.push({
url: uploadResult.url,
imetaTag
});
// Insert file URL into textarea (plain URL for all file types)
if (textareaRef) {
insertTextAtCursor(textareaRef, `${uploadResult.url}\n`);
}
} catch (error) {
console.error(`[CreateEventForm] File upload failed for ${file.name}:`, error);
const errorMessage = error instanceof Error ? error.message : String(error);
alert(`Failed to upload ${file.name}: ${errorMessage}`);
}
})();
uploadPromises.push(uploadPromise);
}
// Wait for all uploads to complete
try {
await Promise.all(uploadPromises);
} finally {
uploading = false;
// Reset file input
if (fileInputRef) {
fileInputRef.value = '';
}
}
}
async function publish() {
const session = sessionManager.getSession();
@ -699,6 +610,9 @@ @@ -699,6 +610,9 @@
content = '';
tags = [];
uploadedFiles = []; // Clear uploaded files after successful publish
if (richTextEditorRef) {
richTextEditorRef.clearUploadedFiles();
}
// Clear draft from IndexedDB after successful publish
await deleteDraft(DRAFT_ID);
setTimeout(() => {
@ -839,47 +753,16 @@ @@ -839,47 +753,16 @@
{#if !isKind30040 && !isKind10895}
<div class="form-group">
<label for="content-textarea" class="form-label">Content</label>
<div class="textarea-wrapper">
<textarea
id="content-textarea"
bind:this={textareaRef}
<RichTextEditor
bind:this={richTextEditorRef}
bind:value={content}
class="content-input has-buttons"
rows="10"
placeholder="Event content..."
rows={10}
disabled={publishing}
></textarea>
{#if textareaRef}
<MentionsAutocomplete textarea={textareaRef} />
{/if}
<div class="textarea-buttons">
<button
type="button"
onclick={() => {
showGifPicker = !showGifPicker;
showEmojiPicker = false;
}}
class="toolbar-button"
title="Insert GIF"
aria-label="Insert GIF"
disabled={publishing}
>
GIF
</button>
<button
type="button"
onclick={() => { showEmojiPicker = !showEmojiPicker; showGifPicker = false; }}
class="toolbar-button"
title="Insert emoji"
aria-label="Insert emoji"
disabled={publishing}
>
😀
</button>
</div>
</div>
showToolbar={true}
uploadContext="CreateEventForm"
onFilesUploaded={handleFilesUploaded}
/>
<div class="content-buttons">
<button
@ -912,23 +795,6 @@ @@ -912,23 +795,6 @@
>
Clear
</button>
<input
type="file"
bind:this={fileInputRef}
accept="image/*,video/*,audio/*"
multiple
onchange={handleFileUpload}
class="hidden"
id="write-file-upload"
disabled={publishing || uploading}
/>
<label
for="write-file-upload"
class="content-button upload-label"
title="Upload file (image, video, or audio)"
>
📤
</label>
</div>
</div>
{/if}
@ -984,9 +850,6 @@ @@ -984,9 +850,6 @@
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
<GifPicker open={showGifPicker} onSelect={handleGifSelect} onClose={() => showGifPicker = false} />
<EmojiPicker open={showEmojiPicker} onSelect={handleEmojiSelect} onClose={() => showEmojiPicker = false} />
<!-- JSON View Modal -->
{#if showJsonModal}
<div
@ -1400,31 +1263,6 @@ @@ -1400,31 +1263,6 @@
color: var(--fog-dark-text, #cbd5e1);
}
.content-input {
width: 100%;
padding: 0.75rem;
padding-bottom: 3.5rem; /* Extra padding for buttons at bottom */
padding-left: 0.75rem; /* Ensure left padding */
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #475569);
font-size: 0.875rem;
font-family: monospace;
resize: vertical;
box-sizing: border-box;
}
.content-input.has-buttons {
padding-bottom: 3.5rem; /* Extra padding when buttons are present */
padding-left: 0.75rem; /* Ensure text doesn't overlap left-positioned buttons */
}
:global(.dark) .content-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #cbd5e1);
}
.tags-list {
display: flex;
@ -1611,54 +1449,6 @@ @@ -1611,54 +1449,6 @@
cursor: not-allowed;
}
.textarea-wrapper {
position: relative;
width: 100%;
}
.textarea-buttons {
position: absolute;
bottom: 0.75rem;
left: 0.75rem;
display: flex;
gap: 0.25rem;
z-index: 10;
pointer-events: auto;
}
.toolbar-button {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #475569);
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.toolbar-button:hover:not(:disabled) {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .toolbar-button {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #cbd5e1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
:global(.dark) .toolbar-button:hover:not(:disabled) {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-accent, #64748b);
}
.content-buttons {
display: flex;
@ -1711,14 +1501,6 @@ @@ -1711,14 +1501,6 @@
cursor: not-allowed;
}
.upload-label {
user-select: none;
filter: grayscale(100%);
}
.hidden {
display: none;
}
/* Modal styles */
.modal-overlay {

47
src/lib/modules/comments/Comment.svelte

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import DiscussionVoteButtons from '../discussions/DiscussionVoteButtons.svelte';
import EventMenu from '../../components/EventMenu.svelte';
import CommentForm from './CommentForm.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
@ -17,12 +18,16 @@ @@ -17,12 +18,16 @@
onReply?: (comment: NostrEvent) => void;
rootEventKind?: number; // The kind of the root event (e.g., 11 for threads)
reactions?: NostrEvent[]; // Optional pre-loaded reactions (for performance)
threadId?: string; // The root event ID for the thread
rootEvent?: NostrEvent; // The root event for the thread
onCommentPublished?: () => void; // Callback when a comment is published
}
let { comment, parentEvent, onReply, rootEventKind, reactions: providedReactions }: Props = $props();
let { comment, parentEvent, onReply, rootEventKind, reactions: providedReactions, threadId, rootEvent, onCommentPublished }: Props = $props();
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
let showReplyForm = $state(false);
// DiscussionVoteButtons handles all vote counting internally
@ -44,10 +49,22 @@ @@ -44,10 +49,22 @@
return clientTag?.[1] || null;
}
function handleReply() {
if (threadId) {
// Show reply form directly below this comment
showReplyForm = !showReplyForm;
} else {
// Fallback to parent callback if no threadId
onReply?.(comment);
}
}
async function handleCommentPublished() {
showReplyForm = false;
if (onCommentPublished) {
onCommentPublished();
}
}
$effect(() => {
if (contentElement) {
@ -89,12 +106,12 @@ @@ -89,12 +106,12 @@
<div class="comment-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={comment.pubkey} />
<span class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.75em;">{getRelativeTime()}</span>
<span class="text-fog-text-light dark:text-fog-dark-text-light whitespace-nowrap" style="font-size: 0.75em;">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.75em;">via {getClientName()}</span>
<span class="text-fog-text-light dark:text-fog-dark-text-light whitespace-nowrap" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
<div class="ml-auto">
<EventMenu event={comment} />
<EventMenu event={comment} showContentActions={true} onReply={handleReply} />
</div>
</div>
@ -130,6 +147,19 @@ @@ -130,6 +147,19 @@
</button>
</div>
<!-- Reply form appears directly below this comment -->
{#if showReplyForm && threadId}
<div class="reply-form-container mt-4">
<CommentForm
threadId={threadId}
rootEvent={rootEvent}
parentEvent={comment}
onPublished={handleCommentPublished}
onCancel={() => showReplyForm = false}
/>
</div>
{/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(comment.kind).number}</span>
<span class="kind-description">{getKindInfo(comment.kind).description}</span>
@ -159,8 +189,10 @@ @@ -159,8 +189,10 @@
.comment-actions {
padding-right: 6rem; /* Reserve space for kind badge */
padding-top: 0.5rem;
padding-bottom: 0.5rem; /* Add bottom padding to prevent overlap with kind badge */
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
margin-bottom: 0.5rem; /* Add margin to prevent overlap with kind badge */
/* Ensure footer is always visible, even when content is collapsed */
position: relative;
z-index: 1;
@ -217,4 +249,9 @@ @@ -217,4 +249,9 @@
font-size: 0.625rem;
opacity: 0.8;
}
.reply-form-container {
padding-bottom: 2.5rem; /* Add padding to prevent overlap with kind badge */
position: relative;
}
</style>

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

@ -7,16 +7,13 @@ @@ -7,16 +7,13 @@
import { fetchRelayLists } from '../../services/user-data.js';
import { getDraft, saveDraft, deleteDraft } from '../../services/cache/draft-store.js';
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte';
import GifPicker from '../../components/content/GifPicker.svelte';
import EmojiPicker from '../../components/content/EmojiPicker.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import { insertTextAtCursor } from '../../services/text-utils.js';
import RichTextEditor from '../../components/content/RichTextEditor.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import MentionsAutocomplete from '../../components/content/MentionsAutocomplete.svelte';
import { extractMentions, getMentionPubkeys } from '../../services/mentions.js';
interface Props {
@ -75,13 +72,9 @@ @@ -75,13 +72,9 @@
});
let showStatusModal = $state(false);
let publicationResults: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = $state(null);
let showGifPicker = $state(false);
let showEmojiPicker = $state(false);
let showJsonModal = $state(false);
let showPreviewModal = $state(false);
let textareaRef: HTMLTextAreaElement | null = $state(null);
let fileInputRef: HTMLInputElement | null = $state(null);
let uploading = $state(false);
let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
let eventJson = $state('{}');
const isLoggedIn = $derived(sessionManager.isLoggedIn());
@ -274,22 +267,16 @@ @@ -274,22 +267,16 @@
if (confirm('Are you sure you want to clear the comment? This will delete all unsaved content.')) {
content = '';
uploadedFiles = [];
if (richTextEditorRef) {
richTextEditorRef.clearUploadedFiles();
}
// Clear draft from IndexedDB
await deleteDraft(DRAFT_ID);
}
}
function handleGifSelect(gifUrl: string) {
if (!textareaRef) return;
// Insert GIF URL as plain text
insertTextAtCursor(textareaRef, gifUrl);
showGifPicker = false;
}
function handleEmojiSelect(emoji: string) {
if (!textareaRef) return;
insertTextAtCursor(textareaRef, emoji);
showEmojiPicker = false;
function handleFilesUploaded(files: Array<{ url: string; imetaTag: string[] }>) {
uploadedFiles = files;
}
async function getEventJson(): Promise<string> {
@ -360,104 +347,20 @@ @@ -360,104 +347,20 @@
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Check file type
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isAudio = file.type.startsWith('audio/');
if (!isImage && !isVideo && !isAudio) {
alert('Please select an image, video, or audio file');
return;
}
if (!sessionManager.isLoggedIn()) {
alert('Please log in to upload files');
return;
}
uploading = true;
try {
// Upload file to media server
const uploadResult = await uploadFileToServer(file, 'CommentForm');
console.log(`[CommentForm] Uploaded ${file.name} to ${uploadResult.url}`, { tags: uploadResult.tags });
// Build imeta tag from upload response (NIP-92 format)
const imetaTag = buildImetaTag(file, uploadResult);
console.log(`[CommentForm] Built imeta tag for ${file.name}:`, imetaTag);
// Store file with imeta tag
uploadedFiles.push({
url: uploadResult.url,
imetaTag
});
// Insert file URL into textarea (plain URL for all file types)
if (textareaRef) {
insertTextAtCursor(textareaRef, `${uploadResult.url}\n`);
}
} catch (error) {
console.error('[CommentForm] File upload failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
alert(`Failed to upload file: ${errorMessage}`);
} finally {
uploading = false;
// Reset file input
if (fileInputRef) {
fileInputRef.value = '';
}
}
}
</script>
{#if isLoggedIn}
<div class="comment-form">
<div class="textarea-wrapper">
<textarea
bind:this={textareaRef}
<RichTextEditor
bind:this={richTextEditorRef}
bind:value={content}
placeholder={parentEvent ? 'Write a reply...' : 'Write a comment...'}
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text {showGifButton ? 'has-buttons' : ''}"
rows="4"
disabled={publishing}
></textarea>
{#if textareaRef}
<MentionsAutocomplete textarea={textareaRef} />
{/if}
{#if showGifButton}
<div class="textarea-buttons">
<button
type="button"
onclick={() => {
showGifPicker = !showGifPicker;
showEmojiPicker = false;
}}
class="toolbar-button"
title="Insert GIF"
aria-label="Insert GIF"
rows={4}
disabled={publishing}
>
GIF
</button>
<button
type="button"
onclick={() => { showEmojiPicker = !showEmojiPicker; showGifPicker = false; }}
class="toolbar-button"
title="Insert emoji"
aria-label="Insert emoji"
disabled={publishing}
>
😀
</button>
</div>
{/if}
</div>
showToolbar={showGifButton}
uploadContext="CommentForm"
onFilesUploaded={handleFilesUploaded}
/>
<div class="flex items-center justify-between mt-2 comment-form-actions">
<div class="flex gap-2 comment-form-left">
@ -491,22 +394,6 @@ @@ -491,22 +394,6 @@
>
Clear
</button>
<input
type="file"
bind:this={fileInputRef}
accept="image/*,video/*,audio/*"
onchange={handleFileUpload}
class="hidden"
id="comment-file-upload"
disabled={publishing || uploading}
/>
<label
for="comment-file-upload"
class="px-3 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight disabled:opacity-50 cursor-pointer inline-block upload-label"
title="Upload file (image, video, or audio)"
>
📤
</label>
</div>
<div class="flex gap-2 comment-form-right">
{#if onCancel}
@ -530,11 +417,6 @@ @@ -530,11 +417,6 @@
<PublicationStatusModal bind:open={showStatusModal} bind:results={publicationResults} />
{#if showGifButton}
<GifPicker open={showGifPicker} onSelect={handleGifSelect} onClose={() => showGifPicker = false} />
<EmojiPicker open={showEmojiPicker} onSelect={handleEmojiSelect} onClose={() => showEmojiPicker = false} />
{/if}
<!-- JSON View Modal -->
{#if showJsonModal}
<div
@ -658,65 +540,6 @@ @@ -658,65 +540,6 @@
}
}
.textarea-wrapper {
position: relative;
}
textarea {
resize: vertical;
min-height: 100px;
}
/* Add padding to bottom when buttons are visible to prevent text overlap */
textarea.has-buttons {
padding-bottom: 2.5rem;
}
textarea:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
}
.textarea-buttons {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
display: flex;
gap: 0.25rem;
z-index: 10;
}
.toolbar-button {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
cursor: pointer;
transition: all 0.2s;
}
.toolbar-button:hover:not(:disabled) {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .toolbar-button {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .toolbar-button:hover:not(:disabled) {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-accent, #64748b);
}
/* Modal styles */
.modal-overlay {
@ -862,15 +685,6 @@ @@ -862,15 +685,6 @@
border-color: #64748b;
}
label[for="comment-file-upload"],
.upload-label {
user-select: none;
}
.upload-label {
filter: grayscale(100%);
}
.comment-form-actions {
flex-wrap: wrap;
gap: 0.5rem;
@ -901,9 +715,5 @@ @@ -901,9 +715,5 @@
flex: 1;
min-width: 0;
}
textarea {
font-size: 16px; /* Prevent zoom on iOS */
}
}
</style>

8
src/lib/modules/comments/CommentThread.svelte

@ -15,9 +15,10 @@ @@ -15,9 +15,10 @@
event?: NostrEvent; // The root event itself (optional, used to determine reply types)
onCommentsLoaded?: (eventIds: string[]) => void; // Callback when comments are loaded
preloadedReactions?: Map<string, NostrEvent[]>; // Pre-loaded reactions by event ID
hideCommentForm?: boolean; // If true, don't show the comment form at the bottom
}
let { threadId, event, onCommentsLoaded, preloadedReactions }: Props = $props();
let { threadId, event, onCommentsLoaded, preloadedReactions, hideCommentForm = false }: Props = $props();
let comments = $state<NostrEvent[]>([]); // kind 1111
let kind1Replies = $state<NostrEvent[]>([]); // kind 1 replies (should only be for kind 1 events, but some apps use them for everything)
@ -782,6 +783,9 @@ @@ -782,6 +783,9 @@
parentEvent={parent}
onReply={handleReply}
rootEventKind={rootKind ?? undefined}
threadId={threadId}
rootEvent={event}
onCommentPublished={handleCommentPublished}
/>
{:else if item.type === 'reply'}
<!-- Kind 1 reply - render as FeedPost -->
@ -813,6 +817,7 @@ @@ -813,6 +817,7 @@
</div>
{/if}
{#if !hideCommentForm}
{#if replyingTo}
<div class="reply-form-container mt-4">
<CommentForm
@ -832,6 +837,7 @@ @@ -832,6 +837,7 @@
/>
</div>
{/if}
{/if}
</div>
<style>

65
src/lib/modules/discussions/DiscussionCard.svelte

@ -5,6 +5,9 @@ @@ -5,6 +5,9 @@
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import MetadataCard from '../../components/content/MetadataCard.svelte';
import EventMenu from '../../components/EventMenu.svelte';
import CommentForm from '../comments/CommentForm.svelte';
import { sessionManager } from '../../services/auth/session-manager.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
@ -37,6 +40,8 @@ @@ -37,6 +40,8 @@
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
let lastStatsLoadEventId = $state<string | null>(null);
let showReplyForm = $state(false);
let isLoggedIn = $derived(sessionManager.isLoggedIn());
onMount(async () => {
await loadStats();
@ -211,8 +216,20 @@ @@ -211,8 +216,20 @@
{getTitle()}
</h3>
<div class="flex items-center gap-2">
<span class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.875em;">{getRelativeTime()}</span>
<!-- Menu hidden in preview - not clickable in card preview -->
<span class="text-fog-text-light dark:text-fog-dark-text-light whitespace-nowrap" style="font-size: 0.875em;">{getRelativeTime()}</span>
<div
class="interactive-element"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}}
role="button"
tabindex="0"
>
<EventMenu event={thread} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} />
</div>
</div>
</div>
@ -241,13 +258,25 @@ @@ -241,13 +258,25 @@
</a>
{:else}
<div class="card-content" class:expanded={true} bind:this={contentElement}>
<div class="flex justify-between items-start mb-2">
<h3 class="font-semibold text-fog-text dark:text-fog-dark-text">
<div class="flex justify-between items-center mb-2">
<h3 class="font-semibold text-fog-text dark:text-fog-dark-text flex-1 min-w-0">
{getTitle()}
</h3>
<div class="flex items-center gap-2">
<span class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.875em;">{getRelativeTime()}</span>
<!-- Menu hidden in preview - not clickable in card preview -->
<div class="flex items-center gap-2 flex-shrink-0">
<span class="text-fog-text-light dark:text-fog-dark-text-light whitespace-nowrap" style="font-size: 0.875em;">{getRelativeTime()}</span>
<div
class="interactive-element"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}}
role="button"
tabindex="0"
>
<EventMenu event={thread} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} />
</div>
</div>
</div>
@ -289,7 +318,7 @@ @@ -289,7 +318,7 @@
{/if}
<!-- Card footer (stats) - always visible, outside collapsible content -->
<div class="flex items-center justify-between text-fog-text dark:text-fog-dark-text thread-stats" style="font-size: 0.75em;">
<div class="flex items-center justify-between text-fog-text dark:text-fog-dark-text thread-stats mt-2" style="font-size: 0.75em;">
<div class="flex items-center gap-4 flex-wrap">
{#if fullView}
<DiscussionVoteButtons event={thread} />
@ -334,6 +363,21 @@ @@ -334,6 +363,21 @@
</div>
</article>
{#if isLoggedIn && showReplyForm && fullView}
<div class="reply-form-container">
<CommentForm
threadId={thread.id}
rootEvent={thread}
onPublished={() => {
showReplyForm = false;
}}
onCancel={() => {
showReplyForm = false;
}}
/>
</div>
{/if}
<style>
.thread-card {
max-width: var(--content-width);
@ -477,4 +521,9 @@ @@ -477,4 +521,9 @@
margin: 0.5rem 0;
}
.reply-form-container {
margin-top: 0.5rem;
margin-bottom: 1rem;
}
</style>

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

@ -3,11 +3,43 @@ @@ -3,11 +3,43 @@
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import DiscussionCard from './DiscussionCard.svelte';
import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
import { KIND } from '../../types/kind-lookup.js';
import { getRecentCachedEvents } from '../../services/cache/event-cache.js';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
interface Props {
filterResult?: { type: 'event' | 'pubkey' | 'text' | null; value: string | null };
}
let { filterResult = { type: null, value: null } }: Props = $props();
// Resolved pubkey from filter (handled by parent component's PubkeyFilter)
// For now, we'll do basic normalization here since we don't have access to the filter component
// The parent component should resolve NIP-05 before passing it here
function normalizeToHex(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (/^[a-fA-F0-9]{64}$/.test(trimmed)) {
return trimmed.toLowerCase();
}
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') {
return decoded.data as string;
} else if (decoded.type === 'nprofile') {
return (decoded.data as any).pubkey;
}
} catch {
// Not a valid bech32
}
return null;
}
// Data maps - threads and stats for sorting only (DiscussionCard loads its own stats for display)
let threadsMap = $state<Map<string, NostrEvent>>(new Map()); // threadId -> thread
@ -22,9 +54,6 @@ @@ -22,9 +54,6 @@
let showOlder = $state(false);
let selectedTopic = $state<string | null | undefined>(null); // null = All, undefined = General, string = specific topic
// Thread drawer state
let drawerOpen = $state(false);
let selectedEvent = $state<NostrEvent | null>(null);
// Computed: get sorted and filtered threads from maps
let threads = $derived.by(() => {
@ -73,11 +102,16 @@ @@ -73,11 +102,16 @@
// Skip if we haven't set initial values yet (onMount hasn't run)
if (prevSortBy === null) return;
// Read showOlder to ensure it's tracked by the effect
const currentShowOlder = showOlder;
const currentSortBy = sortBy;
const currentSelectedTopic = selectedTopic;
// Check if any filter parameter actually changed
if (sortBy !== prevSortBy || showOlder !== prevShowOlder || selectedTopic !== prevSelectedTopic) {
prevSortBy = sortBy;
prevShowOlder = showOlder;
prevSelectedTopic = selectedTopic;
if (currentSortBy !== prevSortBy || currentShowOlder !== prevShowOlder || currentSelectedTopic !== prevSelectedTopic) {
prevSortBy = currentSortBy;
prevShowOlder = currentShowOlder;
prevSelectedTopic = currentSelectedTopic;
// Only reload if not already loading
if (!isLoading) {
@ -387,6 +421,7 @@ @@ -387,6 +421,7 @@
voteCountsReady = false;
} finally {
loading = false;
isLoading = false;
}
}
@ -472,13 +507,47 @@ @@ -472,13 +507,47 @@
return events.filter((t) => t.created_at >= cutoffTime);
}
// Get filtered threads (by age and topic) - reactive derived value
// Get filtered threads (by age, topic, and filter result) - reactive derived value
let filteredThreads = $derived.by(() => {
let filtered = threads;
// Filter by age first
filtered = filterByAge(filtered);
// Apply filter based on filterResult type
if (filterResult.type === 'event' && filterResult.value) {
// Filter by specific event ID
filtered = filtered.filter(t => t.id === filterResult.value);
} else if (filterResult.type === 'pubkey' && filterResult.value) {
// Filter by pubkey (should already be normalized hex)
const normalizedPubkey = filterResult.value.toLowerCase();
if (/^[a-f0-9]{64}$/i.test(normalizedPubkey)) {
filtered = filtered.filter(t => t.pubkey.toLowerCase() === normalizedPubkey);
}
} else if (filterResult.type === 'text' && filterResult.value) {
// Filter by text search (pubkey, p, q, and content fields)
const queryLower = filterResult.value.toLowerCase();
filtered = filtered.filter(event => {
// Search pubkey
const pubkeyMatch = event.pubkey.toLowerCase().includes(queryLower);
// Search p tags
const pTagMatch = event.tags.some(tag =>
tag[0] === 'p' && tag[1]?.toLowerCase().includes(queryLower)
);
// Search q tags
const qTagMatch = event.tags.some(tag =>
tag[0] === 'q' && tag.some((val, idx) => idx > 0 && val?.toLowerCase().includes(queryLower))
);
// Search content
const contentMatch = event.content.toLowerCase().includes(queryLower);
return pubkeyMatch || pTagMatch || qTagMatch || contentMatch;
});
}
// Then filter by topic
// selectedTopic === null means "All" - show all threads
if (selectedTopic === null) {
@ -526,28 +595,22 @@ @@ -526,28 +595,22 @@
return result;
}
function openThreadDrawer(event: NostrEvent, e?: MouseEvent) {
// Don't open drawer if clicking on interactive elements
function navigateToEvent(event: NostrEvent, e?: MouseEvent) {
// Don't navigate if clicking on interactive elements
if (e) {
const target = e.target as HTMLElement;
if (target.closest('button') || target.closest('a') || target.closest('[role="button"]')) {
return;
}
}
selectedEvent = event;
drawerOpen = true;
}
function closeThreadDrawer() {
drawerOpen = false;
selectedEvent = null;
goto(`/event/${event.id}`);
}
onMount(() => {
// Listen for custom event from EmbeddedEvent components
const handleOpenEvent = (e: CustomEvent) => {
if (e.detail?.event) {
openThreadDrawer(e.detail.event);
navigateToEvent(e.detail.event);
}
};
@ -575,12 +638,6 @@ @@ -575,12 +638,6 @@
<input
type="checkbox"
bind:checked={showOlder}
onchange={() => {
// If showing older threads, reload to fetch them
if (showOlder) {
loadAllData();
}
}}
/>
Show older posts (than 30 days)
</label>
@ -621,13 +678,13 @@ @@ -621,13 +678,13 @@
<div
data-thread-id={thread.id}
class="thread-wrapper"
onclick={(e) => openThreadDrawer(thread, e)}
onclick={(e) => navigateToEvent(thread, e)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openThreadDrawer(thread);
navigateToEvent(thread);
}
}}
>
@ -647,12 +704,6 @@ @@ -647,12 +704,6 @@
{/if}
</div>
<ThreadDrawer
opEvent={selectedEvent}
isOpen={drawerOpen}
onClose={closeThreadDrawer}
/>
<style>
.thread-list {
max-width: var(--content-width);

21
src/lib/modules/events/EventView.svelte

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
<script lang="ts">
import FeedPost from '../feed/FeedPost.svelte';
import CommentThread from '../comments/CommentThread.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { loadEventIndex, type EventIndexItem, type MissingEventInfo } from '../../services/nostr/event-index-loader.js';
@ -231,7 +232,16 @@ @@ -231,7 +232,16 @@
{/if}
<!-- Render the event itself -->
<FeedPost post={item.event} />
<div class="event-with-comments">
<FeedPost post={item.event} fullView={true} />
<!-- Load and display comments for each event in the index -->
{#if item.event.kind !== 30040}
<div class="comments-section mt-4">
<CommentThread threadId={item.event.id} event={item.event} />
</div>
{/if}
</div>
<!-- Recursively render children if this is a nested index -->
{#if item.children && item.children.length > 0}
@ -271,8 +281,15 @@ @@ -271,8 +281,15 @@
{:else}
<!-- Display regular events using FeedPost -->
<div class="event-section">
<FeedPost post={rootEvent} />
<FeedPost post={rootEvent} fullView={true} />
</div>
<!-- Load and display comments for all event types -->
{#if rootEvent && !isMetadataOnly}
<div class="comments-section mt-6">
<CommentThread threadId={rootEvent.id} event={rootEvent} />
</div>
{/if}
{/if}
</article>
{:else}

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

@ -10,12 +10,13 @@ @@ -10,12 +10,13 @@
interface Props {
singleRelay?: string;
filterResult?: { type: 'event' | 'pubkey' | 'text' | null; value: string | null };
}
let { singleRelay }: Props = $props();
let { singleRelay, filterResult = { type: null, value: null } }: Props = $props();
// Core state
let events = $state<NostrEvent[]>([]);
let allEvents = $state<NostrEvent[]>([]);
let loading = $state(true);
let relayError = $state<string | null>(null);
@ -33,16 +34,55 @@ @@ -33,16 +34,55 @@
let initialLoadComplete = $state(false);
let loadInProgress = $state(false);
// Filtered events based on filterResult
let filteredEvents = $derived.by(() => {
if (!filterResult.value) {
return allEvents;
}
const queryLower = filterResult.value.toLowerCase();
if (filterResult.type === 'pubkey') {
// Filter by exact pubkey match
return allEvents.filter((event: NostrEvent) => {
if (event.pubkey.toLowerCase() === queryLower) return true;
// Check p tags
if (event.tags.some(tag => tag[0] === 'p' && tag[1]?.toLowerCase() === queryLower)) return true;
// Check q tags
if (event.tags.some(tag => tag[0] === 'q' && tag.some((val, idx) => idx > 0 && val?.toLowerCase() === queryLower))) return true;
return false;
});
} else if (filterResult.type === 'text') {
// Filter by text search in pubkey, p tags, q tags, and content
return allEvents.filter((event: NostrEvent) => {
const pubkeyMatch = event.pubkey.toLowerCase().includes(queryLower);
const pTagMatch = event.tags.some(tag =>
tag[0] === 'p' && tag[1]?.toLowerCase().includes(queryLower)
);
const qTagMatch = event.tags.some(tag =>
tag[0] === 'q' && tag.some((val, idx) => idx > 0 && val?.toLowerCase().includes(queryLower))
);
const contentMatch = event.content.toLowerCase().includes(queryLower);
return pubkeyMatch || pTagMatch || qTagMatch || contentMatch;
});
}
return allEvents;
});
// Use filteredEvents for display
let events = $derived(filteredEvents);
// Load waiting room events into feed
function loadWaitingRoomEvents() {
if (waitingRoomEvents.length === 0) return;
const eventMap = new Map(events.map(e => [e.id, e]));
const eventMap = new Map(allEvents.map((e: NostrEvent) => [e.id, e]));
for (const event of waitingRoomEvents) {
eventMap.set(event.id, event);
}
events = Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at);
allEvents = Array.from(eventMap.values()).sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at);
waitingRoomEvents = [];
}
@ -86,14 +126,14 @@ @@ -86,14 +126,14 @@
return;
}
const eventMap = new Map(events.map(e => [e.id, e]));
const eventMap = new Map(allEvents.map((e: NostrEvent) => [e.id, e]));
for (const event of filtered) {
eventMap.set(event.id, event);
}
const sorted = Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at);
events = sorted;
oldestTimestamp = Math.min(...sorted.map(e => e.created_at));
const sorted = Array.from(eventMap.values()).sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at);
allEvents = sorted;
oldestTimestamp = Math.min(...sorted.map((e: NostrEvent) => e.created_at));
} catch (error) {
console.error('Error loading older events:', error);
} finally {
@ -120,9 +160,9 @@ @@ -120,9 +160,9 @@
);
if (filtered.length > 0 && isMounted) {
const unique = Array.from(new Map(filtered.map(e => [e.id, e])).values());
events = unique.sort((a, b) => b.created_at - a.created_at);
oldestTimestamp = Math.min(...events.map(e => e.created_at));
const unique = Array.from(new Map(filtered.map((e: NostrEvent) => [e.id, e])).values());
allEvents = unique.sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at);
oldestTimestamp = Math.min(...allEvents.map((e: NostrEvent) => e.created_at));
loading = false; // Show cached content immediately
}
}
@ -165,16 +205,16 @@ @@ -165,16 +205,16 @@
getKindInfo(e.kind).showInFeed === true
);
const eventMap = new Map(events.map(e => [e.id, e]));
const eventMap = new Map(allEvents.map((e: NostrEvent) => [e.id, e]));
for (const event of filtered) {
eventMap.set(event.id, event);
}
const sorted = Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at);
events = sorted;
const sorted = Array.from(eventMap.values()).sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at);
allEvents = sorted;
if (sorted.length > 0) {
oldestTimestamp = Math.min(...sorted.map(e => e.created_at));
oldestTimestamp = Math.min(...sorted.map((e: NostrEvent) => e.created_at));
}
} catch (error) {
console.error('Error loading feed:', error);
@ -203,7 +243,7 @@ @@ -203,7 +243,7 @@
if (!isMounted || event.kind === KIND.DISCUSSION_THREAD || !initialLoadComplete) return;
// Add to waiting room if not already in feed or waiting room
const eventIds = new Set([...events.map(e => e.id), ...waitingRoomEvents.map(e => e.id)]);
const eventIds = new Set([...allEvents.map((e: NostrEvent) => e.id), ...waitingRoomEvents.map((e: NostrEvent) => e.id)]);
if (!eventIds.has(event.id)) {
waitingRoomEvents = [...waitingRoomEvents, event].sort((a, b) => b.created_at - a.created_at);
}
@ -272,6 +312,16 @@ @@ -272,6 +312,16 @@
</div>
{/if}
<div class="load-more-section-top">
<button
onclick={loadOlderEvents}
disabled={loadingMore || !hasMoreEvents}
class="see-more-events-btn"
>
{loadingMore ? 'Loading...' : 'See more events'}
</button>
</div>
<div class="feed-posts">
{#each events as event (event.id)}
<FeedPost post={event} />
@ -404,7 +454,8 @@ @@ -404,7 +454,8 @@
background: var(--fog-dark-text, #cbd5e1);
}
.load-more-section {
.load-more-section,
.load-more-section-top {
padding: 2rem;
text-align: center;
}

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

@ -133,6 +133,18 @@ @@ -133,6 +133,18 @@
return stripMarkdown(post.content);
}
// Check if this is a highlight event with context tag
function hasContextTag(): boolean {
if (post.kind !== KIND.HIGHLIGHTED_ARTICLE) return false;
return post.tags.some(t => t[0] === 'context' && t[1]);
}
// Get highlight content to highlight (for highlight events with context)
function getHighlightContent(): string | null {
if (!hasContextTag()) return null;
return post.content.trim() || null;
}
// Parse NIP-21 links and create segments for rendering
interface ContentSegment {
type: 'text' | 'profile' | 'event' | 'url' | 'wikilink' | 'hashtag';
@ -597,22 +609,14 @@ @@ -597,22 +609,14 @@
</h2>
{/if}
<div class="post-header flex flex-col gap-2 mb-2">
<div class="flex items-center justify-end gap-2 flex-nowrap">
<div class="post-header-actions flex items-center gap-2 flex-shrink-0">
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if}
<EventMenu event={post} showContentActions={true} />
</div>
</div>
<div class="flex items-center gap-2 flex-nowrap">
<div class="flex-shrink-1 min-w-0">
<div class="post-header flex items-center justify-between gap-2 mb-2">
<div class="flex items-center gap-2 flex-nowrap flex-1 min-w-0">
<div class="flex-shrink-0">
<ProfileBadge pubkey={post.pubkey} />
</div>
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap" style="font-size: 0.75em;">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">via {getClientName()}</span>
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
{#if post.kind === KIND.DISCUSSION_THREAD}
{@const topics = getTopics()}
@ -625,6 +629,12 @@ @@ -625,6 +629,12 @@
{/if}
{/if}
</div>
<div class="post-header-actions flex items-center gap-2 flex-shrink-0">
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if}
<EventMenu event={post} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} />
</div>
<hr class="post-header-divider" />
</div>
@ -638,14 +648,14 @@ @@ -638,14 +648,14 @@
</div>
{:else}
<!-- Feed view: plaintext only, no profile pics, media as URLs -->
<div class="post-header flex flex-col gap-2 mb-2">
<div class="flex items-center gap-2 flex-nowrap">
<div class="flex-shrink-1 min-w-0">
<div class="post-header flex items-center justify-between gap-2 mb-2">
<div class="flex items-center gap-2 flex-nowrap flex-1 min-w-0">
<div class="flex-shrink-0">
<ProfileBadge pubkey={post.pubkey} inline={true} />
</div>
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap" style="font-size: 0.75em;">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">via {getClientName()}</span>
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
{#if post.kind === KIND.DISCUSSION_THREAD}
{@const topics = getTopics()}
@ -671,8 +681,19 @@ @@ -671,8 +681,19 @@
<div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}>
<p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap">
{#each parseContentWithNIP21Links() as segment}
{@const highlightContent = getHighlightContent()}
{#if segment.type === 'text'}
{#if highlightContent && segment.content.includes(highlightContent)}
{@const parts = segment.content.split(highlightContent)}
{#each parts as part, i}
{part}
{#if i < parts.length - 1}
<mark class="highlighted-text">{highlightContent}</mark>
{/if}
{/each}
{:else}
{segment.content}
{/if}
{:else if segment.type === 'profile' && segment.pubkey}
<ProfileBadge pubkey={segment.pubkey} inline={true} />
{:else if segment.type === 'event' && segment.eventId}
@ -760,7 +781,7 @@ @@ -760,7 +781,7 @@
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if}
<EventMenu event={post} showContentActions={true} />
<EventMenu event={post} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} />
</div>
<div class="kind-badge feed-card-kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
@ -778,18 +799,7 @@ @@ -778,18 +799,7 @@
{/if}
</article>
{#if isLoggedIn}
<div class="reply-section mb-2">
<button
onclick={() => showReplyForm = !showReplyForm}
class="reply-btn text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 0.875em;"
>
Reply
</button>
</div>
{#if showReplyForm}
{#if isLoggedIn && showReplyForm}
<div class="reply-form-container mb-4">
<CommentForm
threadId={post.id}
@ -802,7 +812,6 @@ @@ -802,7 +812,6 @@
}}
/>
</div>
{/if}
{/if}
{#if mediaViewerUrl && mediaViewerOpen}
@ -875,6 +884,7 @@ @@ -875,6 +884,7 @@
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
padding-top: 1rem;
}
.word-wrap {
@ -887,10 +897,12 @@ @@ -887,10 +897,12 @@
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start; /* Left-align URLs */
}
.media-url-link {
word-break: break-all;
text-align: left; /* Ensure left alignment */
}
.nostr-event-link {
@ -969,19 +981,24 @@ @@ -969,19 +981,24 @@
display: flex;
align-items: center;
line-height: 1.5;
}
.post-header-actions {
flex-shrink: 0;
position: relative;
}
.post-header-divider {
margin: 0 0 0.5rem 0;
position: absolute;
bottom: -0.5rem;
left: 0;
right: 0;
margin: 0;
border: none;
border-top: 1px solid var(--fog-border, #e5e7eb);
width: 100%;
}
.post-header-actions {
flex-shrink: 0;
}
:global(.dark) .post-header-divider {
border-top-color: var(--fog-dark-border, #374151);
}
@ -1013,4 +1030,15 @@ @@ -1013,4 +1030,15 @@
filter: grayscale(0%);
}
.highlighted-text {
background-color: rgba(255, 255, 0, 0.3);
padding: 0.125rem 0.25rem;
border-radius: 0.125rem;
font-weight: 500;
}
:global(.dark) .highlighted-text {
background-color: rgba(255, 255, 0, 0.2);
}
</style>

2
src/lib/modules/feed/HighlightCard.svelte

@ -323,7 +323,7 @@ @@ -323,7 +323,7 @@
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if}
<EventMenu event={highlight} showContentActions={true} />
<EventMenu event={highlight} showContentActions={true} onReply={() => {}} />
</div>
</div>
</div>

2
src/lib/modules/feed/Reply.svelte

@ -92,7 +92,7 @@ @@ -92,7 +92,7 @@
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if}
<div class="ml-auto">
<EventMenu event={reply} showContentActions={reply.kind === KIND.SHORT_TEXT_NOTE} />
<EventMenu event={reply} showContentActions={true} onReply={() => {}} />
</div>
</div>

561
src/lib/modules/feed/ThreadDrawer.svelte

@ -1,561 +0,0 @@ @@ -1,561 +0,0 @@
<script lang="ts">
import FeedPost from './FeedPost.svelte';
import CommentThread from '../comments/CommentThread.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import { buildEventHierarchy, getHierarchyChain } from '../../services/nostr/event-hierarchy.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
interface Props {
opEvent: NostrEvent | null;
isOpen: boolean;
onClose: () => void;
}
let { opEvent, isOpen, onClose }: Props = $props();
let drawerElement: HTMLElement | null = $state(null);
let loading = $state(false);
let subscriptionId: string | null = $state(null);
let isInitialized = $state(false);
let hierarchyChain = $state<NostrEvent[]>([]);
let currentLoadEventId = $state<string | null>(null); // Track which event is currently being loaded
let lastOpEventId = $state<string | null>(null); // Track the last event ID to prevent reactive loops
let loadFullThread = $state(false); // Only load full thread when "View thread" is clicked
// Batch-loaded reactions and zaps for all events in thread
let reactionsByEventId = $state<Map<string, NostrEvent[]>>(new Map());
let zapsByEventId = $state<Map<string, NostrEvent[]>>(new Map());
let loadingReactions = $state(false);
// Derive event ID separately to avoid reactive loops from object reference changes
let opEventId = $derived(opEvent?.id || null);
// Initialize nostr client once
onMount(async () => {
if (!isInitialized) {
await nostrClient.initialize();
isInitialized = true;
}
});
// Build event hierarchy when drawer opens
async function loadHierarchy(abortSignal: AbortSignal, eventId: string) {
if (!opEvent || !isInitialized) {
console.warn('loadHierarchy: opEvent or isInitialized missing', { opEvent: !!opEvent, isInitialized });
loading = false;
return;
}
// If we're already loading this event, don't start another load
if (currentLoadEventId === eventId && loading) {
console.log('loadHierarchy: Already loading this event', eventId);
return;
}
currentLoadEventId = eventId;
loading = true;
console.log('loadHierarchy: Starting load for event', eventId);
try {
// Add timeout to prevent hanging - use longer timeout for hierarchy building
// Hierarchy building can take time on slow connections as it fetches parent events recursively
// Use singleRelayTimeout (15s) as hierarchy building is similar in complexity
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('buildEventHierarchy timeout - loading thread hierarchy took too long')), config.singleRelayTimeout);
});
const hierarchy = await Promise.race([
buildEventHierarchy(opEvent),
timeoutPromise
]) as Awaited<ReturnType<typeof buildEventHierarchy>>;
// Check if operation was aborted or event changed
if (abortSignal.aborted || currentLoadEventId !== eventId) {
console.log('loadHierarchy: Aborted or event changed', { aborted: abortSignal.aborted, currentId: currentLoadEventId, eventId });
return;
}
const chain = getHierarchyChain(hierarchy);
console.log('loadHierarchy: Got hierarchy chain', chain.length, 'events');
// Check again before final state update
if (abortSignal.aborted || currentLoadEventId !== eventId) {
console.log('loadHierarchy: Aborted before setting chain');
return;
}
hierarchyChain = chain;
// After hierarchy is loaded, batch fetch reactions and zaps for all events
if (!abortSignal.aborted && currentLoadEventId === eventId) {
batchLoadReactionsAndZaps(chain);
}
} catch (error) {
// Only update state if not aborted and still loading this event
if (abortSignal.aborted || currentLoadEventId !== eventId) {
console.log('loadHierarchy: Error but aborted or event changed');
return;
}
console.error('Error building event hierarchy:', error);
hierarchyChain = [opEvent]; // Fallback to just the event
// Still try to batch load reactions/zaps for the single event
if (!abortSignal.aborted && currentLoadEventId === eventId) {
batchLoadReactionsAndZaps([opEvent]);
}
} finally {
// Only update loading state if not aborted and still loading this event
if (!abortSignal.aborted && currentLoadEventId === eventId) {
loading = false;
console.log('loadHierarchy: Finished loading', eventId);
} else {
console.log('loadHierarchy: Not updating loading state', { aborted: abortSignal.aborted, currentId: currentLoadEventId, eventId });
}
}
}
// Batch fetch reactions and zaps for all events in the thread
async function batchLoadReactionsAndZaps(events: NostrEvent[]) {
if (loadingReactions || events.length === 0) return;
loadingReactions = true;
try {
// Collect all event IDs from hierarchy
const allEventIds = new Set<string>();
for (const event of events) {
allEventIds.add(event.id);
}
await batchLoadReactionsForEvents(Array.from(allEventIds));
} catch (error) {
console.error('[ThreadDrawer] Error batch loading reactions/zaps:', error);
} finally {
loadingReactions = false;
}
}
// Batch fetch reactions and zaps for a set of event IDs
async function batchLoadReactionsForEvents(eventIds: string[]) {
if (eventIds.length === 0) return;
const allEventIds = new Set([...Array.from(reactionsByEventId.keys()), ...eventIds]);
const eventIdsArray = Array.from(allEventIds);
const reactionRelays = relayManager.getProfileReadRelays();
// Batch fetch reactions for all events
const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': eventIdsArray, limit: config.feedLimit }],
reactionRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' }
);
const reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#E': eventIdsArray, limit: config.feedLimit }],
reactionRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' }
);
// Group reactions by event ID
const reactionsMap = new Map(reactionsByEventId);
for (const reaction of [...reactionsWithLowerE, ...reactionsWithUpperE]) {
// Find which event this reaction is for
const eTag = reaction.tags.find(t => t[0] === 'e' || t[0] === 'E');
if (eTag && eTag[1] && allEventIds.has(eTag[1])) {
const eventId = eTag[1];
if (!reactionsMap.has(eventId)) {
reactionsMap.set(eventId, []);
}
reactionsMap.get(eventId)!.push(reaction);
}
}
// Batch fetch zap receipts for all events
const zapReceipts = await nostrClient.fetchEvents(
[{ kinds: [KIND.ZAP_RECEIPT], '#e': eventIdsArray, limit: config.feedLimit }],
reactionRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' }
);
// Group zap receipts by event ID
const zapsMap = new Map(zapsByEventId);
for (const zap of zapReceipts) {
const eTag = zap.tags.find(t => t[0] === 'e');
if (eTag && eTag[1] && allEventIds.has(eTag[1])) {
const eventId = eTag[1];
if (!zapsMap.has(eventId)) {
zapsMap.set(eventId, []);
}
zapsMap.get(eventId)!.push(zap);
}
}
reactionsByEventId = reactionsMap;
zapsByEventId = zapsMap;
}
// Function to trigger full thread loading
function loadFullThreadHierarchy() {
if (!opEvent || !isInitialized || loadFullThread) return;
loadFullThread = true;
const eventId = opEvent.id;
// Create abort controller to track operation lifecycle
const abortController = new AbortController();
// Load hierarchy with abort signal
loadHierarchy(abortController.signal, eventId).catch((error) => {
// Handle any unhandled promise rejections
if (!abortController.signal.aborted) {
console.error('ThreadDrawer: Unhandled error in loadHierarchy', error);
loading = false;
hierarchyChain = opEvent ? [opEvent] : [];
}
});
}
// Handle drawer open/close - reset state when opening/closing
$effect(() => {
const eventId = opEventId;
// Only proceed if event ID actually changed or drawer state changed
if (!isOpen || !eventId || !isInitialized || !opEvent) {
// Drawer closed or no event - cleanup
if (!isOpen || !eventId) {
currentLoadEventId = null;
lastOpEventId = null;
loadFullThread = false;
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
hierarchyChain = [];
loading = false;
reactionsByEventId.clear();
zapsByEventId.clear();
} else if (!opEvent) {
// Event ID exists but opEvent is null - might be loading, set loading to false
console.warn('ThreadDrawer: opEvent is null but eventId exists', eventId);
loading = false;
}
return;
}
// Reset loadFullThread when event changes
if (lastOpEventId !== eventId) {
loadFullThread = false;
hierarchyChain = [];
lastOpEventId = eventId;
// Load reactions for the single event when drawer opens
if (opEvent) {
batchLoadReactionsForEvents([opEvent.id]);
}
}
// Don't auto-load hierarchy - only load when "View thread" is clicked
// The effect just handles cleanup and state reset
});
// Handle keyboard events
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen) {
onClose();
}
}
// Handle backdrop click
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
// Prevent body scroll when drawer is open
$effect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}
});
</script>
{#if isOpen && opEvent}
<div
class="drawer-backdrop"
onclick={handleBackdropClick}
onkeydown={handleKeyDown}
role="button"
tabindex="0"
aria-label="Close thread drawer"
></div>
<div
class="thread-drawer drawer-right"
bind:this={drawerElement}
onkeydown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-label="Thread drawer"
tabindex="-1"
>
<div class="drawer-header">
<h3 class="drawer-title">Thread</h3>
<button
onclick={onClose}
class="drawer-close"
aria-label="Close thread drawer"
title="Close"
>
×
</button>
</div>
<div class="drawer-content">
{#if loading}
<div class="loading-state">
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p>
</div>
{:else}
<div class="thread-content">
{#if !loadFullThread}
<!-- Single event view - show just the event with "View thread" button -->
<div class="op-post">
<FeedPost
post={opEvent}
fullView={true}
preloadedReactions={reactionsByEventId.get(opEvent.id)}
/>
</div>
<div class="view-thread-prompt">
<button
onclick={loadFullThreadHierarchy}
class="view-thread-btn text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 1em; padding: 0.5rem 1rem; margin: 1rem 0;"
>
View thread
</button>
</div>
{:else if hierarchyChain.length > 0}
<!-- Display full event hierarchy (root to leaf) -->
{#each hierarchyChain as parentEvent, index (parentEvent.id)}
<div class="hierarchy-post">
{#if index > 0}
<div class="hierarchy-divider">
<span class="hierarchy-label">Replying to:</span>
</div>
{/if}
<FeedPost
post={parentEvent}
fullView={true}
preloadedReactions={reactionsByEventId.get(parentEvent.id)}
/>
</div>
{/each}
{:else}
<!-- Fallback: just show the event -->
<div class="op-post">
<FeedPost
post={opEvent}
fullView={true}
preloadedReactions={reactionsByEventId.get(opEvent.id)}
/>
</div>
{/if}
<!-- Display comments/replies only when full thread is loaded -->
{#if loadFullThread}
<div class="comments-section">
<CommentThread
threadId={opEvent.id}
event={opEvent}
preloadedReactions={reactionsByEventId}
onCommentsLoaded={(commentEventIds) => {
// When comments are loaded, also batch fetch reactions for them
if (commentEventIds.length > 0) {
batchLoadReactionsForEvents(commentEventIds);
}
}}
/>
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}
<style>
.drawer-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.thread-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(600px, 90vw);
max-width: 600px;
background: var(--fog-post, #ffffff);
border-left: 2px solid var(--fog-border, #cbd5e1);
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.2);
padding: 0;
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideInRight 0.3s ease-out;
transform: translateX(0);
}
:global(.dark) .thread-drawer {
background: var(--fog-dark-post, #1f2937);
border-left-color: var(--fog-dark-border, #475569);
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.5);
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .drawer-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.drawer-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-title {
color: var(--fog-dark-text, #f9fafb);
}
.drawer-close {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.drawer-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-close {
color: var(--fog-dark-text-light, #6b7280);
}
:global(.dark) .drawer-close:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.drawer-content {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
padding: 0;
}
.loading-state {
padding: 2rem;
text-align: center;
}
.thread-content {
display: flex;
flex-direction: column;
}
.op-post {
padding: 1rem;
border-bottom: 2px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .op-post {
border-bottom-color: var(--fog-dark-border, #374151);
}
.hierarchy-post {
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .hierarchy-post {
border-bottom-color: var(--fog-dark-border, #374151);
}
.hierarchy-divider {
padding: 0.5rem 0;
margin-bottom: 0.5rem;
border-bottom: 1px dashed var(--fog-border, #e5e7eb);
}
:global(.dark) .hierarchy-divider {
border-bottom-color: var(--fog-dark-border, #374151);
}
.hierarchy-label {
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
font-style: italic;
}
:global(.dark) .hierarchy-label {
color: var(--fog-dark-text-light, #9ca3af);
}
.comments-section {
padding: 1rem;
flex: 1;
min-height: 0;
}
</style>

2
src/lib/modules/feed/ZapReceiptReply.svelte

@ -100,7 +100,7 @@ @@ -100,7 +100,7 @@
<span class="text-sm font-semibold">{getAmount().toLocaleString()} sats</span>
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
<div class="ml-auto">
<EventMenu event={zapReceipt} />
<EventMenu event={zapReceipt} showContentActions={true} />
</div>
</div>

2
src/lib/types/kind-lookup.ts

@ -108,7 +108,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = { @@ -108,7 +108,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
// Articles
[KIND.LONG_FORM_NOTE]: { number: KIND.LONG_FORM_NOTE, description: 'Long-form Note', showInFeed: true, isSecondaryKind: false },
[KIND.HIGHLIGHTED_ARTICLE]: { number: KIND.HIGHLIGHTED_ARTICLE, description: 'Highlighted Article', showInFeed: true, isSecondaryKind: false },
[KIND.HIGHLIGHTED_ARTICLE]: { number: KIND.HIGHLIGHTED_ARTICLE, description: 'Highlighted Article', showInFeed: false, isSecondaryKind: false },
// Threads and comments
[KIND.DISCUSSION_THREAD]: { number: KIND.DISCUSSION_THREAD, description: 'Discussion Thread', showInFeed: false, isSecondaryKind: false }, // Only shown on /discussions page

21
src/routes/+layout.svelte

@ -12,8 +12,27 @@ @@ -12,8 +12,27 @@
let { children }: Props = $props();
// Restore session immediately if in browser (before onMount)
// Initialize theme and preferences from localStorage immediately (before any components render)
if (browser) {
// Initialize theme
const storedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = storedTheme === 'dark' || (!storedTheme && prefersDark);
if (shouldBeDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Initialize other preferences
const textSize = localStorage.getItem('textSize') || 'medium';
const lineSpacing = localStorage.getItem('lineSpacing') || 'normal';
const contentWidth = localStorage.getItem('contentWidth') || 'medium';
document.documentElement.setAttribute('data-text-size', textSize);
document.documentElement.setAttribute('data-line-spacing', lineSpacing);
document.documentElement.setAttribute('data-content-width', contentWidth);
// Try to restore session synchronously if possible
// This ensures session is restored before any components render
(async () => {

419
src/routes/bookmarks/+page.svelte

@ -1,40 +1,82 @@ @@ -1,40 +1,82 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import FeedPost from '../../lib/modules/feed/FeedPost.svelte';
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { config } from '../../lib/services/nostr/config.js';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { KIND } from '../../lib/types/kind-lookup.js';
import { nip19 } from 'nostr-tools';
let allEvents = $state<NostrEvent[]>([]);
interface BookmarkOrHighlight {
event: NostrEvent;
type: 'bookmark' | 'highlight';
authorPubkey: string; // Who created the bookmark/highlight
}
let allItems = $state<BookmarkOrHighlight[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let currentPage = $state(1);
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
let typeFilter = $state<'all' | 'bookmark' | 'highlight'>('all');
function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) {
filterResult = result;
}
const itemsPerPage = 100;
const maxTotalBookmarks = 500;
const maxTotalItems = 500;
// Get logged-in user's pubkey
let currentUserPubkey = $derived(sessionManager.getSession()?.pubkey || null);
// Computed: filtered items based on filter result and type filter
let filteredItems = $derived.by(() => {
let filtered = allItems;
// Filter by type (bookmark/highlight)
if (typeFilter === 'bookmark') {
filtered = filtered.filter(item => item.type === 'bookmark');
} else if (typeFilter === 'highlight') {
filtered = filtered.filter(item => item.type === 'highlight');
}
// typeFilter === 'all' shows everything
// Filter by pubkey if provided
if (filterResult.value && filterResult.type === 'pubkey') {
const normalizedPubkey = filterResult.value.toLowerCase();
if (/^[a-f0-9]{64}$/i.test(normalizedPubkey)) {
filtered = filtered.filter(item => item.authorPubkey.toLowerCase() === normalizedPubkey.toLowerCase());
}
}
return filtered;
});
// Computed: get events for current page
let paginatedEvents = $derived.by(() => {
let paginatedItems = $derived.by(() => {
const start = (currentPage - 1) * itemsPerPage;
const end = start + itemsPerPage;
return allEvents.slice(start, end);
return filteredItems.slice(start, end);
});
// Computed: total pages
let totalPages = $derived.by(() => Math.ceil(allEvents.length / itemsPerPage));
let totalPages = $derived.by(() => Math.ceil(filteredItems.length / itemsPerPage));
async function loadBookmarks() {
async function loadBookmarksAndHighlights() {
loading = true;
error = null;
allEvents = [];
allItems = [];
currentPage = 1;
try {
// Fetch all kind 10003 (bookmark) events from relays
const relays = relayManager.getFeedReadRelays();
const items: BookmarkOrHighlight[] = [];
// 1. Fetch bookmark lists (kind 10003)
const fetchedBookmarkLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.BOOKMARKS], limit: config.feedLimit }],
relays,
@ -45,31 +87,170 @@ @@ -45,31 +87,170 @@
}
);
// Extract all event IDs from bookmark lists
const bookmarkedIds = new Set<string>();
// Extract event IDs from bookmark lists
const bookmarkMap = new Map<string, string>(); // eventId -> authorPubkey
for (const bookmarkList of fetchedBookmarkLists) {
for (const tag of bookmarkList.tags) {
if (tag[0] === 'e' && tag[1]) {
bookmarkedIds.add(tag[1]);
bookmarkMap.set(tag[1], bookmarkList.pubkey);
}
}
}
console.log(`[Bookmarks] Found ${bookmarkMap.size} unique bookmarked event IDs from ${fetchedBookmarkLists.length} bookmark lists`);
// 2. Fetch highlight events (kind 9802)
// Use profile read relays for highlights (they might be on different relays)
const profileRelays = relayManager.getProfileReadRelays();
const allRelaysForHighlights = [...new Set([...relays, ...profileRelays])];
// Fetch highlights - we want ALL highlights, not just from specific authors
// This will show highlights from everyone, which is what we want for the bookmarks page
const highlightEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: config.feedLimit }],
allRelaysForHighlights,
{
useCache: true,
cacheResults: true,
timeout: config.standardTimeout
}
);
console.log(`[Bookmarks] Found ${highlightEvents.length} highlight events from ${allRelaysForHighlights.length} relays`);
// Extract event IDs from highlights (e-tags and a-tags)
const highlightMap = new Map<string, string>(); // eventId -> authorPubkey
const aTagHighlights = new Map<string, { highlight: NostrEvent; pubkey: string }>(); // a-tag -> highlight info
let highlightsWithETags = 0;
let highlightsWithATags = 0;
let highlightsWithNoRefs = 0;
// First pass: extract e-tags and collect a-tags
for (const highlight of highlightEvents) {
let hasRef = false;
// Extract e-tag (direct event reference)
const eTag = highlight.tags.find(t => t[0] === 'e' && t[1]);
if (eTag && eTag[1]) {
highlightMap.set(eTag[1], highlight.pubkey);
highlightsWithETags++;
hasRef = true;
}
// Extract a-tag (addressable event: kind:pubkey:d-tag)
const aTag = highlight.tags.find(t => t[0] === 'a' && t[1]);
if (aTag && aTag[1]) {
aTagHighlights.set(aTag[1], { highlight, pubkey: highlight.pubkey });
if (!hasRef) highlightsWithATags++;
hasRef = true;
}
if (!hasRef) {
highlightsWithNoRefs++;
// Log a sample of highlights without refs for debugging
if (highlightsWithNoRefs <= 3) {
console.debug(`[Bookmarks] Highlight ${highlight.id.substring(0, 16)}... has no e-tag or a-tag. Tags:`, highlight.tags.map(t => t[0]).join(', '));
}
}
}
console.log(`[Bookmarks] Found ${bookmarkedIds.size} unique bookmarked event IDs from ${fetchedBookmarkLists.length} bookmark lists`);
console.log(`[Bookmarks] Found ${highlightMap.size} e-tag references and ${aTagHighlights.size} a-tag references`);
console.log(`[Bookmarks] Highlights breakdown: ${highlightsWithETags} with e-tags, ${highlightsWithATags} with a-tags only, ${highlightsWithNoRefs} with no event references`);
// Second pass: fetch events for a-tags in batches
if (aTagHighlights.size > 0) {
const aTagFilters: any[] = [];
const aTagToPubkey = new Map<string, string>();
for (const [aTag, info] of aTagHighlights.entries()) {
const aTagParts = aTag.split(':');
if (aTagParts.length >= 2) {
const kind = parseInt(aTagParts[0]);
const pubkey = aTagParts[1];
const dTag = aTagParts[2] || '';
const filter: any = {
kinds: [kind],
authors: [pubkey],
limit: 1
};
if (dTag) {
filter['#d'] = [dTag];
}
aTagFilters.push(filter);
aTagToPubkey.set(aTag, info.pubkey);
}
}
if (bookmarkedIds.size === 0) {
// Fetch all a-tag events
if (aTagFilters.length > 0) {
try {
const aTagEvents = await nostrClient.fetchEvents(
aTagFilters,
allRelaysForHighlights,
{
useCache: true,
cacheResults: true,
timeout: config.standardTimeout
}
);
// Match a-tag events back to highlights
for (const event of aTagEvents) {
// Find which a-tag this event matches
for (const [aTag, info] of aTagHighlights.entries()) {
const aTagParts = aTag.split(':');
if (aTagParts.length >= 2) {
const kind = parseInt(aTagParts[0]);
const pubkey = aTagParts[1];
const dTag = aTagParts[2] || '';
if (event.kind === kind && event.pubkey === pubkey) {
// Check d-tag if present
if (dTag) {
const eventDTag = event.tags.find(t => t[0] === 'd' && t[1]);
if (eventDTag && eventDTag[1] === dTag) {
highlightMap.set(event.id, info.pubkey);
break;
}
} else {
// No d-tag, just match kind and pubkey
highlightMap.set(event.id, info.pubkey);
break;
}
}
}
}
}
console.log(`[Bookmarks] Resolved ${aTagEvents.length} events from a-tags`);
} catch (err) {
console.error('[Bookmarks] Error fetching events for a-tags:', err);
}
}
}
console.log(`[Bookmarks] Total extracted ${highlightMap.size} event IDs from ${highlightEvents.length} highlight events`);
// Combine all event IDs
const allEventIds = new Set([...bookmarkMap.keys(), ...highlightMap.keys()]);
if (allEventIds.size === 0) {
loading = false;
return;
}
// Limit to maxTotalBookmarks
const eventIds = Array.from(bookmarkedIds).slice(0, maxTotalBookmarks);
if (bookmarkedIds.size > maxTotalBookmarks) {
console.log(`[Bookmarks] Limiting to ${maxTotalBookmarks} bookmarks (found ${bookmarkedIds.size})`);
// Limit to maxTotalItems
const eventIds = Array.from(allEventIds).slice(0, maxTotalItems);
if (allEventIds.size > maxTotalItems) {
console.log(`[Bookmarks] Limiting to ${maxTotalItems} items (found ${allEventIds.size})`);
}
// Fetch the actual events - batch to avoid relay limits
const batchSize = config.veryLargeBatchLimit; // Use config batch limit
const batchSize = config.veryLargeBatchLimit;
const allFetchedEvents: NostrEvent[] = [];
console.log(`[Bookmarks] Fetching ${eventIds.length} events in batches of ${batchSize}`);
@ -96,19 +277,46 @@ @@ -96,19 +277,46 @@
console.log(`[Bookmarks] Total fetched: ${allFetchedEvents.length} events`);
// Sort by created_at (newest first) and limit to maxTotalBookmarks
allEvents = allFetchedEvents.sort((a, b) => b.created_at - a.created_at).slice(0, maxTotalBookmarks);
// Create BookmarkOrHighlight items
for (const event of allFetchedEvents) {
const isBookmark = bookmarkMap.has(event.id);
const isHighlight = highlightMap.has(event.id);
if (isBookmark) {
items.push({
event,
type: 'bookmark',
authorPubkey: bookmarkMap.get(event.id)!
});
} else if (isHighlight) {
items.push({
event,
type: 'highlight',
authorPubkey: highlightMap.get(event.id)!
});
}
}
// Sort by created_at (newest first) and limit to maxTotalItems
allItems = items.sort((a, b) => b.event.created_at - a.event.created_at).slice(0, maxTotalItems);
} catch (err) {
console.error('Error loading bookmarks:', err);
error = err instanceof Error ? err.message : 'Failed to load bookmarks';
console.error('Error loading bookmarks and highlights:', err);
error = err instanceof Error ? err.message : 'Failed to load bookmarks and highlights';
} finally {
loading = false;
}
}
// Reset to page 1 when filter changes
$effect(() => {
filterResult; // Track filterResult
typeFilter; // Track typeFilter
currentPage = 1;
});
onMount(async () => {
await nostrClient.initialize();
await loadBookmarks();
await loadBookmarksAndHighlights();
});
</script>
@ -120,29 +328,62 @@ @@ -120,29 +328,62 @@
{#if loading}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading bookmarks...</p>
<p class="text-fog-text dark:text-fog-dark-text">Loading bookmarks and highlights...</p>
</div>
{:else if error}
<div class="error-state">
<p class="text-fog-text dark:text-fog-dark-text error-message">{error}</p>
</div>
{:else if allEvents.length === 0}
{:else if allItems.length === 0}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No bookmarks found.</p>
<p class="text-fog-text dark:text-fog-dark-text">No bookmarks or highlights found.</p>
</div>
{:else}
<div class="filters-section mb-4">
<div class="type-filter-section">
<label for="type-filter" class="type-filter-label">Filter:</label>
<select
id="type-filter"
bind:value={typeFilter}
class="type-filter-select"
aria-label="Filter by type"
>
<option value="all">Bookmarks and highlights</option>
<option value="bookmark">Bookmarks</option>
<option value="highlight">Highlights</option>
</select>
</div>
<div class="search-filter-section">
<UnifiedSearch mode="filter" onFilterChange={handleFilterChange} placeholder="Filter by pubkey..." />
</div>
</div>
<div class="bookmarks-info">
<p class="text-fog-text dark:text-fog-dark-text text-sm">
Showing {paginatedEvents.length} of {allEvents.length} bookmarks
{#if allEvents.length >= maxTotalBookmarks}
(limited to {maxTotalBookmarks})
Showing {paginatedItems.length} of {filteredItems.length} items
{#if allItems.length >= maxTotalItems}
(limited to {maxTotalItems})
{/if}
{#if filterResult.value}
(filtered)
{/if}
</p>
</div>
<div class="bookmarks-posts">
{#each paginatedEvents as event (event.id)}
<FeedPost post={event} />
{#each paginatedItems as item (item.event.id)}
<div class="bookmark-item-wrapper">
<div class="bookmark-indicator-wrapper">
<span
class="bookmark-emoji"
class:grayscale={currentUserPubkey?.toLowerCase() !== item.authorPubkey.toLowerCase()}
title={item.type === 'bookmark' ? 'Bookmark' : 'Highlight'}
>
{item.type === 'bookmark' ? '🔖' : '✨'}
</span>
</div>
<FeedPost post={item.event} />
</div>
{/each}
</div>
@ -177,6 +418,38 @@ @@ -177,6 +418,38 @@
</button>
</div>
{/if}
{#if totalPages > 1}
<div class="pagination pagination-bottom">
<button
class="pagination-button"
disabled={currentPage === 1}
onclick={() => {
if (currentPage > 1) currentPage--;
}}
aria-label="Previous page"
>
Previous
</button>
<div class="pagination-info">
<span class="text-fog-text dark:text-fog-dark-text">
Page {currentPage} of {totalPages}
</span>
</div>
<button
class="pagination-button"
disabled={currentPage === totalPages}
onclick={() => {
if (currentPage < totalPages) currentPage++;
}}
aria-label="Next page"
>
Next
</button>
</div>
{/if}
{/if}
</div>
</main>
@ -260,4 +533,88 @@ @@ -260,4 +533,88 @@
min-width: 100px;
text-align: center;
}
.filters-section {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.type-filter-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.type-filter-label {
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
font-weight: 500;
}
:global(.dark) .type-filter-label {
color: var(--fog-dark-text, #f9fafb);
}
.type-filter-select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
cursor: pointer;
min-width: 200px;
font-family: inherit;
}
.type-filter-select:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .type-filter-select {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .type-filter-select:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.search-filter-section {
width: 100%;
}
.bookmark-item-wrapper {
position: relative;
}
.bookmark-indicator-wrapper {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 10;
pointer-events: none;
}
.bookmark-item-wrapper :global(.Feed-post) {
padding-left: 2.5rem; /* Make room for the icon */
}
.bookmark-emoji {
display: inline-block;
font-size: 1.25rem;
line-height: 1;
filter: grayscale(100%);
transition: filter 0.2s;
}
.bookmark-emoji:not(.grayscale) {
filter: grayscale(0%);
}
</style>

50
src/routes/discussions/+page.svelte

@ -1,10 +1,16 @@ @@ -1,10 +1,16 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import DiscussionList from '../../lib/modules/discussions/DiscussionList.svelte';
import SearchBox from '../../lib/components/layout/SearchBox.svelte';
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { onMount } from 'svelte';
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) {
filterResult = result;
}
onMount(async () => {
await nostrClient.initialize();
});
@ -25,10 +31,10 @@ @@ -25,10 +31,10 @@
</div>
<div class="search-section mb-6">
<SearchBox />
<UnifiedSearch mode="filter" onFilterChange={handleFilterChange} placeholder="Filter by pubkey, p, q tags, or content..." />
</div>
<DiscussionList />
<DiscussionList filterResult={filterResult} />
</div>
</main>
@ -43,9 +49,47 @@ @@ -43,9 +49,47 @@
justify-content: space-between;
align-items: flex-start;
padding: 0 1rem;
position: sticky;
top: 0;
background: var(--fog-bg, #ffffff);
z-index: 10;
padding-top: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .discussions-header {
background: var(--fog-dark-bg, #0f172a);
border-bottom-color: var(--fog-dark-border, #1e293b);
}
.search-section {
padding: 0 1rem;
display: flex;
justify-content: flex-start;
position: sticky;
top: 0;
background: var(--fog-bg, #ffffff);
z-index: 9;
padding-top: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .search-section {
background: var(--fog-dark-bg, #0f172a);
border-bottom-color: var(--fog-dark-border, #1e293b);
}
.search-section :global(.unified-search-container) {
max-width: 100%;
width: 100%;
}
.search-section :global(.search-input) {
max-width: 100%;
width: 100%;
}
</style>

29
src/routes/feed/+page.svelte

@ -1,10 +1,16 @@ @@ -1,10 +1,16 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import FeedPage from '../../lib/modules/feed/FeedPage.svelte';
import SearchBox from '../../lib/components/layout/SearchBox.svelte';
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { onMount } from 'svelte';
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) {
filterResult = result;
}
onMount(async () => {
await nostrClient.initialize();
});
@ -18,14 +24,18 @@ @@ -18,14 +24,18 @@
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Feed</h1>
<div class="feed-controls">
<div class="search-section">
<SearchBox />
<UnifiedSearch
mode="filter"
onFilterChange={handleFilterChange}
placeholder="Filter by pubkey, p, q tags, or content..."
/>
</div>
<a href="/write?kind=1" class="write-button" title="Write a new post">
<span class="emoji emoji-grayscale"></span>
</a>
</div>
</div>
<FeedPage />
<FeedPage filterResult={filterResult} />
</div>
</main>
@ -40,6 +50,19 @@ @@ -40,6 +50,19 @@
flex-direction: column;
gap: 1rem;
padding: 0 1rem;
position: sticky;
top: 0;
background: var(--fog-bg, #ffffff);
z-index: 10;
padding-top: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .feed-header {
background: var(--fog-dark-bg, #0f172a);
border-bottom-color: var(--fog-dark-border, #1e293b);
}
.feed-controls {

4
src/routes/feed/relay/[relay]/+page.svelte

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<script lang="ts">
import Header from '../../../../lib/components/layout/Header.svelte';
import FeedPage from '../../../../lib/modules/feed/FeedPage.svelte';
import SearchBox from '../../../../lib/components/layout/SearchBox.svelte';
import UnifiedSearch from '../../../../lib/components/layout/UnifiedSearch.svelte';
import RelayInfo from '../../../../lib/components/relay/RelayInfo.svelte';
import { nostrClient } from '../../../../lib/services/nostr/nostr-client.js';
import { onMount } from 'svelte';
@ -67,7 +67,7 @@ @@ -67,7 +67,7 @@
<main class="container mx-auto px-4 py-8">
<div class="relay-feed-content">
<div class="search-section mb-6">
<SearchBox />
<UnifiedSearch mode="search" />
</div>
{#if error}

450
src/routes/find/+page.svelte

@ -1,115 +1,41 @@ @@ -1,115 +1,41 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import FindEventForm from '../../lib/components/write/FindEventForm.svelte';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let userInput = $state('');
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import FeedPost from '../../lib/modules/feed/FeedPost.svelte';
import ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte';
import { KIND, KIND_LOOKUP } from '../../lib/types/kind-lookup.js';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte';
let selectedKind = $state<number | null>(null);
let unifiedSearchComponent: { triggerSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } } | null = $state(null);
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
let searching = $state(false);
let error = $state<string | null>(null);
let initialEventId = $state<string | null>(null);
// Check for event ID in URL query parameters
$effect(() => {
const eventId = $page.url.searchParams.get('event');
if (eventId) {
initialEventId = eventId;
}
});
/**
* Decode pubkey from various formats
*/
async function decodePubkey(input: string): Promise<string | null> {
const trimmed = input.trim();
// Check if it's already a hex pubkey (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
// Check if it's a bech32 format (npub, nprofile)
if (/^(npub|nprofile)1[a-z0-9]+$/i.test(trimmed)) {
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') {
return String(decoded.data);
} else if (decoded.type === 'nprofile') {
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
return String(decoded.data.pubkey);
}
}
} catch (e) {
console.error('Error decoding bech32:', e);
return null;
}
}
// Check if it's a NIP-05 identifier (user@domain.com)
if (/^[^@]+@[^@]+\.[^@]+$/.test(trimmed)) {
return await resolveNIP05(trimmed);
}
return null;
}
/**
* Resolve NIP-05 identifier to pubkey
*/
async function resolveNIP05(identifier: string): Promise<string | null> {
try {
const [localPart, domain] = identifier.split('@');
if (!localPart || !domain) return null;
// Check cache first (we'd need to implement a cache for this)
// For now, just fetch from well-known
const wellKnownUrl = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`;
const response = await fetch(wellKnownUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const names = data.names || {};
const pubkey = names[localPart];
if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) {
return pubkey.toLowerCase();
function handleKindChange(e: Event) {
const select = e.target as HTMLSelectElement;
selectedKind = select.value === '' ? null : parseInt(select.value);
}
return null;
} catch (err) {
console.error('Error resolving NIP-05:', err);
return null;
function handleSearch() {
if (unifiedSearchComponent) {
searching = true;
unifiedSearchComponent.triggerSearch();
}
}
async function findUser() {
if (!userInput.trim()) return;
searching = true;
error = null;
try {
const pubkey = await decodePubkey(userInput.trim());
if (!pubkey) {
error = 'Could not decode user identifier. Supported: NIP-05, hex pubkey, npub, nprofile';
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
searchResults = results;
searching = false;
return;
}
// Navigate to profile page
await goto(`/profile/${pubkey}`);
} catch (err) {
console.error('Error finding user:', err);
error = 'Failed to find user';
searching = false;
}
}
// Get all kinds for dropdown (sorted by number)
const allKinds = Object.values(KIND_LOOKUP).sort((a, b) => a.number - b.number);
onMount(async () => {
await nostrClient.initialize();
});
</script>
<Header />
@ -119,42 +45,86 @@ @@ -119,42 +45,86 @@
<h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Find</h1>
<div class="find-sections">
<!-- Find Event Section -->
<section class="find-section">
<FindEventForm initialEventId={initialEventId} />
</section>
<!-- Find User Section -->
<section class="find-section">
<h2>Find User</h2>
<p class="section-description">Enter a user ID (NIP-05, hex pubkey, npub, or nprofile)</p>
<div class="input-group">
<input
type="text"
bind:value={userInput}
placeholder="user@domain.com or npub1..."
class="user-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
findUser();
}
}}
disabled={searching}
<h2>Search Events</h2>
<p class="section-description">Search for events by ID, pubkey, NIP-05, or content. Use the kind filter to narrow results.</p>
<div class="search-container">
<div class="search-bar-wrapper">
<UnifiedSearch
mode="search"
bind:this={unifiedSearchComponent}
selectedKind={selectedKind}
hideDropdownResults={true}
onSearchResults={handleSearchResults}
placeholder="Search events, profiles, pubkeys, or enter event ID..."
/>
</div>
<div class="filter-and-button-wrapper">
<div class="kind-filter-wrapper">
<label for="kind-filter" class="kind-filter-label">Filter by Kind:</label>
<select
id="kind-filter"
value={selectedKind?.toString() || ''}
onchange={handleKindChange}
class="kind-filter-select"
aria-label="Filter by kind"
>
<option value="">All Kinds</option>
{#each allKinds as kindInfo}
<option value={kindInfo.number}>{kindInfo.number}: {kindInfo.description}</option>
{/each}
</select>
</div>
<button
class="find-button"
onclick={findUser}
disabled={searching || !userInput.trim()}
class="search-button"
onclick={handleSearch}
disabled={searching}
aria-label="Search"
>
{searching ? 'Searching...' : 'Find'}
{searching ? 'Searching...' : 'Search'}
</button>
</div>
</div>
</section>
{#if searchResults.events.length > 0 || searchResults.profiles.length > 0}
<section class="results-section">
<h2>Search Results</h2>
{#if searchResults.profiles.length > 0}
<div class="results-group">
<h3>Profiles</h3>
<div class="profile-results">
{#each searchResults.profiles as pubkey}
<a href="/profile/{pubkey}" class="profile-result-card">
<ProfileBadge pubkey={pubkey} />
</a>
{/each}
</div>
</div>
{/if}
{#if error}
<div class="error-message">{error}</div>
{#if searchResults.events.length > 0}
<div class="results-group">
<h3>Events ({searchResults.events.length})</h3>
<div class="event-results">
{#each searchResults.events as event}
<a href="/event/{event.id}" class="event-result-card">
<FeedPost post={event} fullView={false} />
</a>
{/each}
</div>
</div>
{/if}
</section>
{:else if !searching && (unifiedSearchComponent?.getFilterResult()?.value)}
<section class="results-section">
<div class="no-results">No results found</div>
</section>
{/if}
</div>
</div>
</main>
@ -205,69 +175,245 @@ @@ -205,69 +175,245 @@
color: var(--fog-dark-text-light, #9ca3af);
}
.input-group {
.search-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-bar-wrapper {
width: 100%;
}
.filter-and-button-wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 640px) {
.filter-and-button-wrapper {
flex-direction: row;
align-items: flex-end;
}
}
.kind-filter-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
.user-input {
@media (min-width: 640px) {
.kind-filter-wrapper {
flex-direction: row;
align-items: center;
flex: 1;
}
}
.kind-filter-label {
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
font-weight: 500;
white-space: nowrap;
}
:global(.dark) .kind-filter-label {
color: var(--fog-dark-text, #f9fafb);
}
.kind-filter-select {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #475569);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: monospace;
cursor: pointer;
width: 100%;
font-family: inherit;
}
@media (min-width: 640px) {
.kind-filter-select {
width: auto;
min-width: 200px;
}
}
:global(.dark) .user-input {
.kind-filter-select:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .kind-filter-select {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #cbd5e1);
color: var(--fog-dark-text, #f9fafb);
}
.user-input:disabled {
opacity: 0.6;
cursor: not-allowed;
:global(.dark) .kind-filter-select:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.find-button {
.search-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
border: none;
border-radius: 0.25rem;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
font-family: monospace;
font-family: inherit;
white-space: nowrap;
transition: all 0.2s;
min-width: 100px;
}
@media (min-width: 640px) {
.search-button {
min-width: auto;
}
}
:global(.dark) .find-button {
:global(.dark) .search-button {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #1f2937);
}
.find-button:hover:not(:disabled) {
.search-button:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.find-button:disabled {
.search-button:active:not(:disabled) {
transform: translateY(0);
}
.search-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background: var(--fog-danger-light, #fee2e2);
color: var(--fog-danger, #dc2626);
border-radius: 0.25rem;
.results-section {
margin-top: 2rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 2rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .results-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.results-section h2 {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #475569);
}
:global(.dark) .results-section h2 {
color: var(--fog-dark-text, #cbd5e1);
}
.results-group {
margin-bottom: 2rem;
}
.results-group:last-child {
margin-bottom: 0;
}
.results-group h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #9ca3af);
}
.profile-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-result-card {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
text-decoration: none;
transition: all 0.2s;
}
:global(.dark) .profile-result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.profile-result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:global(.dark) .profile-result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
.event-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-result-card {
display: block;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
overflow: hidden;
transition: all 0.2s;
text-decoration: none;
color: inherit;
}
:global(.dark) .event-result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.event-result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:global(.dark) .event-result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
.no-results {
padding: 2rem;
text-align: center;
color: var(--fog-text-light, #6b7280);
font-size: 0.875rem;
}
:global(.dark) .error-message {
background: var(--fog-dark-danger-light, #7f1d1d);
color: var(--fog-dark-danger, #ef4444);
:global(.dark) .no-results {
color: var(--fog-dark-text-light, #9ca3af);
}
</style>

22
src/routes/replaceable/[d_tag]/+page.svelte

@ -1,19 +1,17 @@ @@ -1,19 +1,17 @@
<script lang="ts">
import Header from '../../../lib/components/layout/Header.svelte';
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte';
import ThreadDrawer from '../../../lib/modules/feed/ThreadDrawer.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { isReplaceableKind, isParameterizedReplaceableKind } from '../../../lib/types/kind-lookup.js';
import { goto } from '$app/navigation';
let events = $state<NostrEvent[]>([]);
let loading = $state(true);
let dTag = $derived($page.params.d_tag);
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
onMount(async () => {
await nostrClient.initialize();
@ -80,14 +78,8 @@ @@ -80,14 +78,8 @@
}
}
function openInDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
}
function closeDrawer() {
drawerOpen = false;
drawerEvent = null;
function navigateToEvent(event: NostrEvent) {
goto(`/event/${event.id}`);
}
</script>
@ -117,11 +109,11 @@ @@ -117,11 +109,11 @@
{#each events as event (event.id)}
<div
class="event-item"
onclick={() => openInDrawer(event)}
onclick={() => navigateToEvent(event)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openInDrawer(event);
navigateToEvent(event);
}
}}
role="button"
@ -135,10 +127,6 @@ @@ -135,10 +127,6 @@
</div>
</main>
{#if drawerOpen && drawerEvent}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
{/if}
<style>
.replaceable-content {
max-width: var(--content-width);

29
src/routes/repos/+page.svelte

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { onMount } from 'svelte';
@ -12,6 +13,11 @@ @@ -12,6 +13,11 @@
let repos = $state<NostrEvent[]>([]);
let loading = $state(true);
let searchQuery = $state('');
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) {
filterResult = result;
}
onMount(async () => {
await nostrClient.initialize();
@ -169,16 +175,29 @@ @@ -169,16 +175,29 @@
}
}
// Filter repos based on search query
// Filter repos based on search query and pubkey filter
let filteredRepos = $derived.by(() => {
if (!searchQuery.trim()) return repos;
let filtered = repos;
// Filter by pubkey if provided
if (filterResult.value && filterResult.type === 'pubkey') {
const normalizedPubkey = filterResult.value.toLowerCase();
if (/^[a-f0-9]{64}$/i.test(normalizedPubkey)) {
filtered = filtered.filter(repo => repo.pubkey.toLowerCase() === normalizedPubkey);
}
}
// Filter by search query if provided
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
return repos.filter(repo => {
filtered = filtered.filter(repo => {
const name = getRepoName(repo).toLowerCase();
const desc = getRepoDescription(repo).toLowerCase();
return name.includes(query) || desc.includes(query);
});
}
return filtered;
});
</script>
@ -200,6 +219,10 @@ @@ -200,6 +219,10 @@
class="search-input"
/>
</div>
<div class="filter-container mb-4">
<UnifiedSearch mode="filter" onFilterChange={handleFilterChange} placeholder="Filter by pubkey..." />
</div>
</div>
{#if loading}

25
src/routes/repos/[naddr]/+page.svelte

@ -1227,6 +1227,31 @@ @@ -1227,6 +1227,31 @@
</button>
</div>
{/if}
<!-- Pagination controls (bottom) -->
{#if totalPages > 1}
<div class="pagination pagination-bottom">
<button
onclick={() => issuesPage = Math.max(1, issuesPage - 1)}
disabled={issuesPage === 1}
class="pagination-button"
aria-label="Previous page"
>
Previous
</button>
<span class="pagination-info">
Page {issuesPage} of {totalPages} ({filteredIssues.length} {filteredIssues.length === 1 ? 'issue' : 'issues'})
</span>
<button
onclick={() => issuesPage = Math.min(totalPages, issuesPage + 1)}
disabled={issuesPage === totalPages}
class="pagination-button"
aria-label="Next page"
>
Next
</button>
</div>
{/if}
{:else}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No issues found with status "{statusFilter}".</p>

16
src/routes/settings/+page.svelte

@ -16,17 +16,26 @@ @@ -16,17 +16,26 @@
let includeClientTag = $state(true);
onMount(() => {
// Read current preferences from DOM/localStorage (don't apply, just read)
// Read current preferences from localStorage (preferred) or DOM (fallback)
const storedTextSize = localStorage.getItem('textSize') as TextSize | null;
const storedLineSpacing = localStorage.getItem('lineSpacing') as LineSpacing | null;
const storedContentWidth = localStorage.getItem('contentWidth') as ContentWidth | null;
const storedTheme = localStorage.getItem('theme');
textSize = storedTextSize || 'medium';
lineSpacing = storedLineSpacing || 'normal';
contentWidth = storedContentWidth || 'medium';
// Read current theme from DOM (don't change it)
isDark = document.documentElement.classList.contains('dark');
// Read theme from localStorage first, then fallback to DOM/system preference
if (storedTheme) {
isDark = storedTheme === 'dark';
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
isDark = document.documentElement.classList.contains('dark') || prefersDark;
}
// Apply preferences immediately to ensure consistent layout
applyPreferences();
// Load expiring events preference
expiringEvents = hasExpiringEventsEnabled();
@ -262,6 +271,7 @@ @@ -262,6 +271,7 @@
.settings-page {
max-width: var(--content-width);
margin: 0 auto;
padding: 0;
}
.preference-section {

70
src/routes/topics/+page.svelte

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { getEventsByKind } from '../../lib/services/cache/event-cache.js';
@ -7,6 +8,7 @@ @@ -7,6 +8,7 @@
import { goto } from '$app/navigation';
import { KIND } from '../../lib/types/kind-lookup.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
interface TopicInfo {
name: string;
@ -18,6 +20,7 @@ @@ -18,6 +20,7 @@
const ITEM_HEIGHT = 60; // Approximate height of each topic item in pixels
let allTopics = $state<TopicInfo[]>([]);
let allEvents = $state<NostrEvent[]>([]);
let visibleTopics = $state<TopicInfo[]>([]);
let loading = $state(true);
let loadingMore = $state(false);
@ -26,6 +29,45 @@ @@ -26,6 +29,45 @@
let sentinelElement = $state<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
let renderedCount = $state(ITEMS_PER_PAGE);
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) {
filterResult = result;
}
// Filter topics based on filter result
let filteredTopics = $derived.by(() => {
if (!filterResult.value || filterResult.type !== 'pubkey') return allTopics;
const normalizedPubkey = filterResult.value.toLowerCase();
if (!/^[a-f0-9]{64}$/i.test(normalizedPubkey)) return allTopics;
// Count topics only from events by this pubkey
const topicCounts = new Map<string, number>();
const filteredEvents = allEvents.filter(e => e.pubkey.toLowerCase() === normalizedPubkey.toLowerCase());
for (const event of filteredEvents) {
const hashtags = extractHashtags(event);
for (const hashtag of hashtags) {
const current = topicCounts.get(hashtag) || 0;
topicCounts.set(hashtag, current + 1);
}
}
const topicList: TopicInfo[] = Array.from(topicCounts.entries()).map(([name, count]) => ({
name,
count,
isInterest: interestList.includes(name)
}));
topicList.sort((a, b) => {
if (a.isInterest && !b.isInterest) return -1;
if (!a.isInterest && b.isInterest) return 1;
return b.count - a.count;
});
return topicList;
});
function extractHashtags(event: NostrEvent): string[] {
const hashtags = new Set<string>();
@ -72,7 +114,7 @@ @@ -72,7 +114,7 @@
const kind11Events = await getEventsByKind(KIND.DISCUSSION_THREAD, 1000);
allEvents.push(...kind11Events);
// Count hashtags
// Count hashtags (will be filtered by derived if filter is active)
const topicCounts = new Map<string, number>();
for (const event of allEvents) {
@ -110,9 +152,16 @@ @@ -110,9 +152,16 @@
}
function updateVisibleTopics() {
visibleTopics = allTopics.slice(0, renderedCount);
visibleTopics = filteredTopics.slice(0, renderedCount);
hasMore = renderedCount < filteredTopics.length;
}
// Update visible topics when filter changes
$effect(() => {
filterResult; // Track filterResult
updateVisibleTopics();
});
function loadMore() {
if (loadingMore || !hasMore) return;
@ -170,9 +219,13 @@ @@ -170,9 +219,13 @@
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading topics...</p>
{:else if allTopics.length === 0}
{:else if filteredTopics.length === 0}
<p class="text-fog-text dark:text-fog-dark-text">No topics found.</p>
{:else}
<div class="filter-section mb-4">
<UnifiedSearch mode="filter" onFilterChange={handleFilterChange} placeholder="Filter by pubkey..." />
</div>
<div class="topics-container">
<div class="topics-list">
{#each visibleTopics as topic (topic.name)}
@ -208,10 +261,13 @@ @@ -208,10 +261,13 @@
</div>
{/if}
{#if !hasMore && allTopics.length > 0}
{#if !hasMore && filteredTopics.length > 0}
<div class="topics-end">
<p class="text-fog-text-light dark:text-fog-dark-text-light">
Showing all {allTopics.length} topics
Showing all {filteredTopics.length} topics
{#if filterResult.value}
(filtered)
{/if}
</p>
</div>
{/if}
@ -228,6 +284,10 @@ @@ -228,6 +284,10 @@
padding: 0 1rem;
}
.filter-section {
margin-bottom: 1rem;
}
.topics-container {
display: flex;
flex-direction: column;

34
src/routes/topics/[name]/+page.svelte

@ -240,6 +240,40 @@ @@ -240,6 +240,40 @@
</button>
</div>
{/if}
{#if totalPages > 1}
<div class="pagination-controls mt-6 flex justify-center items-center gap-4">
<button
class="pagination-button"
disabled={currentPage === 1}
onclick={() => {
if (currentPage > 1) {
currentPage--;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}}
>
← Previous
</button>
<span class="pagination-info text-fog-text dark:text-fog-dark-text">
Page {currentPage} of {totalPages}
</span>
<button
class="pagination-button"
disabled={currentPage >= totalPages}
onclick={() => {
if (currentPage < totalPages) {
currentPage++;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}}
>
Next →
</button>
</div>
{/if}
{/if}
</div>
</main>

Loading…
Cancel
Save