Browse Source

fix cards and consolidate headers

master
Silberengel 1 month ago
parent
commit
2b5783487f
  1. 4
      public/healthz.json
  2. 57
      src/lib/components/find/SearchAddressableEvents.svelte
  3. 373
      src/lib/components/layout/CardHeader.svelte
  4. 77
      src/lib/components/layout/ProfileBadge.svelte
  5. 110
      src/lib/modules/comments/Comment.svelte
  6. 158
      src/lib/modules/discussions/DiscussionCard.svelte
  7. 81
      src/lib/modules/discussions/DiscussionList.svelte
  8. 109
      src/lib/modules/discussions/DiscussionVoteButtons.svelte
  9. 105
      src/lib/modules/feed/FeedPage.svelte
  10. 301
      src/lib/modules/feed/FeedPost.svelte
  11. 123
      src/lib/modules/feed/HighlightCard.svelte
  12. 88
      src/lib/modules/reactions/FeedReactionButtons.svelte
  13. 6
      src/lib/services/auth/anonymous-signer.ts
  14. 43
      src/lib/services/cache/event-cache.ts
  15. 5
      src/lib/services/nostr/auth-handler.ts
  16. 9
      src/routes/login/+page.svelte

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.2.0", "version": "0.2.0",
"buildTime": "2026-02-07T09:22:55.996Z", "buildTime": "2026-02-10T11:19:46.024Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770456175996 "timestamp": 1770722386024
} }

57
src/lib/components/find/SearchAddressableEvents.svelte

@ -2,7 +2,7 @@
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { getEventsByKind } from '../../services/cache/event-cache.js'; import { getEventsByKind } from '../../services/cache/event-cache.js';
import ProfileBadge from '../layout/ProfileBadge.svelte'; import CardHeader from '../layout/CardHeader.svelte';
import RelayBadge from '../layout/RelayBadge.svelte'; import RelayBadge from '../layout/RelayBadge.svelte';
import CacheBadge from '../layout/CacheBadge.svelte'; import CacheBadge from '../layout/CacheBadge.svelte';
import { getKindInfo, KIND_LOOKUP, isParameterizedReplaceableKind } from '../../types/kind-lookup.js'; import { getKindInfo, KIND_LOOKUP, isParameterizedReplaceableKind } from '../../types/kind-lookup.js';
@ -486,13 +486,15 @@
{/if} {/if}
<div class="card-content"> <div class="card-content">
<div class="card-header"> <CardHeader
<div class="card-header-left"> pubkey={event.pubkey}
<ProfileBadge pubkey={event.pubkey} inline={true} /> inline={true}
kindLabel={getKindInfo(event.kind).description}
>
{#snippet badges()}
<CacheBadge /> <CacheBadge />
</div> {/snippet}
<span class="kind-label">{getKindInfo(event.kind).description}</span> </CardHeader>
</div>
<div class="card-metadata"> <div class="card-metadata">
{#if getTagValue(event, 'd')} {#if getTagValue(event, 'd')}
@ -586,9 +588,12 @@
{/if} {/if}
<div class="card-content"> <div class="card-content">
<div class="card-header"> <CardHeader
<div class="card-header-left"> pubkey={event.pubkey}
<ProfileBadge pubkey={event.pubkey} inline={true} /> inline={true}
kindLabel={getKindInfo(event.kind).description}
>
{#snippet badges()}
{#if relay} {#if relay}
{#if relay === 'cache'} {#if relay === 'cache'}
<CacheBadge /> <CacheBadge />
@ -596,9 +601,8 @@
<RelayBadge relayUrl={relay} /> <RelayBadge relayUrl={relay} />
{/if} {/if}
{/if} {/if}
</div> {/snippet}
<span class="kind-label">{getKindInfo(event.kind).description}</span> </CardHeader>
</div>
<div class="card-metadata"> <div class="card-metadata">
{#if getTagValue(event, 'd')} {#if getTagValue(event, 'd')}
@ -877,33 +881,6 @@
padding: 1rem; padding: 1rem;
} }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 0.5rem;
}
.card-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.kind-label {
font-size: 0.75rem;
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
}
:global(.dark) .kind-label {
color: var(--fog-dark-text-light, #a8b8d0);
background: var(--fog-dark-highlight, #374151);
}
.card-metadata { .card-metadata {
display: flex; display: flex;

373
src/lib/components/layout/CardHeader.svelte

@ -0,0 +1,373 @@
<script lang="ts">
import ProfileBadge from './ProfileBadge.svelte';
import type { Snippet } from 'svelte';
interface Props {
pubkey: string;
relativeTime?: string;
clientName?: string | null;
topics?: string[];
inline?: boolean; // If true, use inline ProfileBadge (no picture)
showDivider?: boolean; // If true, show divider line below header
kindLabel?: string; // Optional kind label to show on the right
badges?: Snippet;
left?: Snippet;
actions?: Snippet;
}
let {
pubkey,
relativeTime,
clientName,
topics = [],
inline = false,
showDivider = false,
kindLabel,
badges,
left,
actions
}: Props = $props();
// Load profile to get NIP-05 for separate display
let profile = $state<{ name?: string; nip05?: string[] } | null>(null);
let lastLoadedPubkey = $state<string | null>(null);
// Check if nip05 handle matches the name to avoid duplicate display
let shouldShowNip05 = $derived.by(() => {
if (!profile?.nip05 || profile.nip05.length === 0) return false;
if (!profile?.name) return true; // Show nip05 if no name
const nip05Handle = profile.nip05[0].split('@')[0]; // Extract handle part before @
return nip05Handle.toLowerCase() !== profile.name.toLowerCase();
});
$effect(() => {
if (pubkey && pubkey !== lastLoadedPubkey) {
profile = null;
loadProfile();
}
});
async function loadProfile() {
if (!pubkey || lastLoadedPubkey === pubkey) return;
const currentPubkey = pubkey;
try {
const { fetchProfile } = await import('../../services/user-data.js');
const p = await fetchProfile(currentPubkey);
if (pubkey === currentPubkey) {
profile = p;
lastLoadedPubkey = currentPubkey;
}
} catch (error) {
// Silently fail - profile loading is non-critical
}
}
</script>
<div class="card-header" class:with-divider={showDivider}>
<div class="card-header-left">
<div class="profile-badge-wrapper">
<ProfileBadge pubkey={pubkey} inline={inline} hideNip05={true} />
</div>
{#if shouldShowNip05 && profile?.nip05}
<span class="nip05-text text-fog-text-light dark:text-fog-dark-text-light nip05-separate">
{profile.nip05[0]}
</span>
{/if}
{#if badges}
{@render badges()}
{/if}
{#if relativeTime}
<span class="time-text">{relativeTime}</span>
{/if}
{#if clientName}
<span class="client-text">via {clientName}</span>
{/if}
{#if topics && topics.length > 0}
{#each topics.slice(0, 3) as topic}
<a href="/topics/{topic}" class="topic-badge">{topic}</a>
{/each}
{/if}
{#if left}
{@render left()}
{/if}
</div>
<div class="card-header-right">
{#if actions}
{@render actions()}
{/if}
{#if kindLabel}
<span class="kind-label">{kindLabel}</span>
{/if}
</div>
{#if showDivider}
<hr class="card-header-divider" />
{/if}
</div>
<style>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
position: relative;
min-width: 0;
flex-wrap: wrap;
width: 100%;
max-width: 100%;
box-sizing: border-box;
overflow: hidden;
word-break: break-word;
overflow-wrap: anywhere;
line-height: 1.5;
}
.card-header.with-divider {
margin-bottom: 1rem;
}
.card-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
flex: 1;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
word-break: break-word;
overflow-wrap: anywhere;
}
.profile-badge-wrapper {
min-width: 0 !important;
max-width: 100% !important;
flex-shrink: 1 !important;
overflow: visible !important;
}
/* Keep profile badge and NIP-05 together, allow time to wrap */
.profile-badge-wrapper {
flex-shrink: 1;
min-width: 0;
}
.nip05-separate {
flex-shrink: 1;
min-width: 0;
}
/* Time can wrap to next line if needed */
.time-text {
flex-shrink: 0;
}
.card-header-left :global(.profile-badge) {
max-width: 100% !important;
width: auto !important;
min-width: 0 !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
box-sizing: border-box !important;
flex-shrink: 1 !important;
flex-wrap: wrap !important;
}
.nip05-separate {
font-size: 0.875em;
white-space: nowrap;
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.nip05-separate {
font-size: 0.875em;
white-space: nowrap;
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark) .nip05-separate {
color: var(--fog-dark-text-light, #a8b8d0);
}
.time-text,
.client-text {
font-size: 0.75em;
color: var(--fog-text-light, #52667a);
white-space: nowrap;
flex-shrink: 0;
}
:global(.dark) .time-text,
:global(.dark) .client-text {
color: var(--fog-dark-text-light, #a8b8d0);
}
.topic-badge {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
background: var(--fog-border, #e5e7eb);
color: var(--fog-text-light, #52667a);
text-decoration: none;
font-size: 0.75em;
transition: opacity 0.2s;
flex-shrink: 0;
}
.topic-badge:hover {
text-decoration: underline;
opacity: 0.8;
}
:global(.dark) .topic-badge {
background: var(--fog-dark-border, #374151);
color: var(--fog-dark-text-light, #a8b8d0);
}
.card-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
flex-wrap: wrap;
}
.kind-label {
font-size: 0.75rem;
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
white-space: nowrap;
}
:global(.dark) .kind-label {
color: var(--fog-dark-text-light, #a8b8d0);
background: var(--fog-dark-highlight, #374151);
}
.card-header-divider {
position: absolute;
bottom: -0.5rem;
left: 0;
right: 0;
margin: 0;
border: none;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .card-header-divider {
border-top-color: var(--fog-dark-border, #374151);
}
/* On wider screens, keep on one line but allow truncation if needed */
@media (min-width: 641px) {
.card-header-left :global(.nip05-container) {
flex-wrap: nowrap !important;
overflow: hidden !important;
}
.card-header-left :global(.profile-name) {
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
flex-shrink: 1 !important;
min-width: 0 !important;
}
.card-header-left :global(.nip05-text),
.card-header-left :global(.break-nip05) {
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
flex-shrink: 1 !important;
min-width: 0 !important;
word-break: normal !important;
overflow-wrap: normal !important;
word-wrap: normal !important;
max-width: 100% !important;
box-sizing: border-box !important;
}
}
@media (max-width: 640px) {
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.card-header-left {
width: 100%;
flex-wrap: wrap;
gap: 0.5rem;
}
.card-header-left > span {
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
max-width: 100%;
flex-shrink: 1;
min-width: 0;
}
.card-header-right {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.profile-badge-wrapper {
max-width: 100% !important;
width: 100% !important;
flex-shrink: 1 !important;
min-width: 0 !important;
}
.card-header-left :global(.profile-badge) {
max-width: 100% !important;
width: 100% !important;
min-width: 0 !important;
flex-shrink: 1 !important;
}
.card-header-left :global(.nip05-container) {
flex-direction: column !important;
align-items: flex-start !important;
width: 100% !important;
}
/* On narrow screens, allow wrapping instead of truncating */
.card-header-left :global(.nip05-text),
.card-header-left :global(.break-nip05),
.card-header-left :global(.nip05-text.break-all),
.card-header-left :global(.break-nip05.break-all),
.card-header-left :global(span.nip05-text),
.card-header-left :global(span.break-nip05),
.card-header-left :global(span.nip05-text.break-all),
.card-header-left :global(span.break-nip05.break-all) {
max-width: none !important;
overflow: visible !important;
text-overflow: clip !important;
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
word-break: normal !important;
overflow-wrap: normal !important;
word-wrap: normal !important;
display: inline-block !important;
width: auto !important;
box-sizing: border-box !important;
}
}
</style>

77
src/lib/components/layout/ProfileBadge.svelte

@ -7,9 +7,10 @@
pubkey: string; pubkey: string;
inline?: boolean; // If true, show only handle/name (no picture, status, etc.) inline?: boolean; // If true, show only handle/name (no picture, status, etc.)
pictureOnly?: boolean; // If true, show only the profile picture/avatar pictureOnly?: boolean; // If true, show only the profile picture/avatar
hideNip05?: boolean; // If true, don't show NIP-05 inside the badge
} }
let { pubkey, inline = false, pictureOnly = false }: Props = $props(); let { pubkey, inline = false, pictureOnly = false, hideNip05 = false }: Props = $props();
let profile = $state<{ name?: string; picture?: string; nip05?: string[] } | null>(null); let profile = $state<{ name?: string; picture?: string; nip05?: string[] } | null>(null);
let activityStatus = $state<'red' | 'yellow' | 'green' | null>(null); let activityStatus = $state<'red' | 'yellow' | 'green' | null>(null);
@ -135,6 +136,15 @@
return pubkey.slice(0, 8) + '...'; return pubkey.slice(0, 8) + '...';
} }
}); });
// Check if nip05 handle matches the name to avoid duplicate display
let shouldShowNip05 = $derived.by(() => {
if (!profile?.nip05 || profile.nip05.length === 0) return false;
if (!profile?.name) return true; // Show nip05 if no name
const nip05Handle = profile.nip05[0].split('@')[0]; // Extract handle part before @
return nip05Handle.toLowerCase() !== profile.name.toLowerCase();
});
</script> </script>
<a href="/profile/{pubkey}" class="profile-badge inline-flex items-start gap-2 min-w-0 max-w-full" class:picture-only={pictureOnly}> <a href="/profile/{pubkey}" class="profile-badge inline-flex items-start gap-2 min-w-0 max-w-full" class:picture-only={pictureOnly}>
@ -192,11 +202,11 @@
{/if} {/if}
{#if !pictureOnly} {#if !pictureOnly}
<div class="flex flex-col min-w-0 flex-1 max-w-full profile-badge-content"> <div class="flex flex-col min-w-0 flex-1 max-w-full profile-badge-content">
<div class="flex items-center gap-2 flex-wrap min-w-0 max-w-full nip05-container"> <div class="flex items-center gap-2 min-w-0 max-w-full nip05-container">
<span class="truncate min-w-0 max-w-full"> <span class="profile-name min-w-0">
{profile?.name || shortenedNpub} {profile?.name || shortenedNpub}
</span> </span>
{#if profile?.nip05 && profile.nip05.length > 0} {#if !hideNip05 && shouldShowNip05 && profile?.nip05}
<span class="nip05-text text-fog-text-light dark:text-fog-dark-text-light min-w-0 break-nip05"> <span class="nip05-text text-fog-text-light dark:text-fog-dark-text-light min-w-0 break-nip05">
{profile.nip05[0]} {profile.nip05[0]}
</span> </span>
@ -234,6 +244,16 @@
max-width: 100% !important; max-width: 100% !important;
box-sizing: border-box !important; box-sizing: border-box !important;
min-width: 0 !important; min-width: 0 !important;
flex-wrap: nowrap !important;
overflow: hidden !important;
}
.profile-name {
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
flex-shrink: 1 !important;
min-width: 0 !important;
} }
.profile-badge:hover { .profile-badge:hover {
@ -315,18 +335,20 @@
/* Base styles for NIP-05 text that apply on all screen sizes */ /* Base styles for NIP-05 text that apply on all screen sizes */
.nip05-text { .nip05-text {
font-size: 0.875em; font-size: 0.875em;
max-width: 100% !important;
flex-shrink: 1 !important; flex-shrink: 1 !important;
min-width: 0 !important; min-width: 0 !important;
display: inline-block !important; white-space: nowrap !important;
width: auto !important; overflow: hidden !important;
text-overflow: ellipsis !important;
box-sizing: border-box !important; box-sizing: border-box !important;
hyphens: auto;
} }
.break-nip05 { .break-nip05 {
max-width: 100% !important; flex-shrink: 1 !important;
display: inline-block !important; min-width: 0 !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@ -336,33 +358,28 @@
} }
.nip05-container { .nip05-container {
flex-direction: column !important; flex-wrap: nowrap !important;
align-items: flex-start !important;
width: 100% !important; width: 100% !important;
} }
/* Truncate handle/name to 10 characters on narrow screens */ /* On narrow screens, allow wrapping if content is too long */
.profile-badge-content .truncate { .nip05-container {
max-width: 10ch !important; flex-wrap: wrap !important;
overflow: hidden !important; }
text-overflow: ellipsis !important;
white-space: nowrap !important; /* Only truncate if it would wrap - let it wrap first */
display: inline-block !important; .profile-name {
max-width: none !important;
} }
/* Truncate NIP-05 to 10 characters on narrow screens */
/* Override both the class selectors and Tailwind's break-all utility */
.nip05-text, .nip05-text,
.break-nip05 { .break-nip05 {
max-width: 10ch !important; max-width: none !important;
overflow: hidden !important; word-break: break-word !important;
text-overflow: ellipsis !important; overflow-wrap: anywhere !important;
white-space: nowrap !important; white-space: normal !important;
display: inline-block !important; overflow: visible !important;
word-break: normal !important; text-overflow: clip !important;
overflow-wrap: normal !important;
word-wrap: normal !important;
box-sizing: border-box !important;
} }
} }
</style> </style>

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

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import CardHeader from '../../components/layout/CardHeader.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte'; import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import ReferencedEventPreview from '../../components/content/ReferencedEventPreview.svelte'; import ReferencedEventPreview from '../../components/content/ReferencedEventPreview.svelte';
@ -116,17 +116,12 @@
<ReferencedEventPreview event={comment} /> <ReferencedEventPreview event={comment} />
{/if} {/if}
<div class="comment-header flex items-center gap-2 mb-2 min-w-0"> <CardHeader
<div class="flex items-center gap-2 flex-1 min-w-0"> pubkey={comment.pubkey}
<div class="flex-shrink-0"> relativeTime={getRelativeTime()}
<ProfileBadge pubkey={comment.pubkey} /> clientName={getClientName()}
</div> >
<span class="text-fog-text-light dark:text-fog-dark-text-light whitespace-nowrap flex-shrink-0" style="font-size: 0.75em;">{getRelativeTime()}</span> {#snippet actions()}
{#if getClientName()}
<span class="text-fog-text-light dark:text-fog-dark-text-light whitespace-nowrap flex-shrink-0" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
</div>
<div class="flex items-center gap-2 comment-header-actions flex-shrink-0">
<IconButton <IconButton
icon="eye" icon="eye"
label="View" label="View"
@ -142,8 +137,8 @@
/> />
{/if} {/if}
<EventMenu event={comment} showContentActions={true} onReply={handleReply} /> <EventMenu event={comment} showContentActions={true} onReply={handleReply} />
</div> {/snippet}
</div> </CardHeader>
<div class="comment-content mb-2"> <div class="comment-content mb-2">
{#if shouldAutoRenderMedia} {#if shouldAutoRenderMedia}
@ -163,20 +158,20 @@
</button> </button>
{/if} {/if}
<!-- Comment actions (vote buttons, reply) - always visible, outside collapsible content --> <!-- Comment actions (vote buttons and kind badge) - always visible, outside collapsible content -->
<div class="comment-actions flex gap-2 items-center"> <div class="comment-actions flex gap-2 items-center justify-between flex-wrap">
{#if rootEventKind === KIND.DISCUSSION_THREAD} <div class="comment-actions-left flex gap-2 items-center">
<!-- DiscussionVoteButtons includes both vote counts and buttons --> {#if rootEventKind === KIND.DISCUSSION_THREAD}
<DiscussionVoteButtons event={comment} /> <!-- DiscussionVoteButtons includes both vote counts and buttons -->
{:else} <DiscussionVoteButtons event={comment} />
<FeedReactionButtons event={comment} /> {:else}
{/if} <FeedReactionButtons event={comment} />
<button {/if}
onclick={handleReply} </div>
class="reply-button text-fog-accent dark:text-fog-dark-accent hover:underline" <div class="kind-badge">
> <span class="kind-number">{getKindInfo(comment.kind).number}</span>
Reply <span class="kind-description">{getKindInfo(comment.kind).description}</span>
</button> </div>
</div> </div>
<!-- Reply form appears directly below this comment --> <!-- Reply form appears directly below this comment -->
@ -191,11 +186,6 @@
/> />
</div> </div>
{/if} {/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(comment.kind).number}</span>
<span class="kind-description">{getKindInfo(comment.kind).description}</span>
</div>
</article> </article>
<style> <style>
@ -226,67 +216,30 @@
} }
.comment-actions { .comment-actions {
padding-right: 6rem; /* Reserve space for kind badge */
padding-top: 0.5rem; padding-top: 0.5rem;
padding-bottom: 0.5rem; /* Add bottom padding to prevent overlap with kind badge */ padding-bottom: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb); border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem; 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 */ /* Ensure footer is always visible, even when content is collapsed */
position: relative; position: relative;
z-index: 1; z-index: 1;
overflow: visible; overflow: visible;
} }
:global(.dark) .comment-actions { .comment-actions-left {
border-top-color: var(--fog-dark-border, #374151); flex: 1;
min-width: 0;
} }
.reply-button { :global(.dark) .comment-actions {
font-size: 0.875rem; border-top-color: var(--fog-dark-border, #374151);
padding: 0.25rem 0.5rem;
background: none;
border: none;
cursor: pointer;
transition: opacity 0.2s;
min-height: 2rem;
display: inline-flex;
align-items: center;
}
.reply-button:hover {
opacity: 0.8;
}
.comment-header-actions {
display: flex !important;
visibility: visible !important;
flex-shrink: 0;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.comment-actions { .comment-actions {
padding-right: 4rem;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
} }
.reply-button {
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
min-height: 2.25rem;
font-weight: 500;
}
.comment-header {
flex-wrap: wrap;
gap: 0.5rem;
}
.comment-header-actions {
gap: 0.375rem;
flex-wrap: wrap;
}
} }
.card-content { .card-content {
@ -311,9 +264,6 @@
} }
.kind-badge { .kind-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@ -321,6 +271,8 @@
font-size: 0.625rem; font-size: 0.625rem;
line-height: 1; line-height: 1;
color: var(--fog-text-light, #52667a); color: var(--fog-text-light, #52667a);
flex-shrink: 0;
white-space: nowrap;
} }
:global(.dark) .kind-badge { :global(.dark) .kind-badge {

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

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import CardHeader from '../../components/layout/CardHeader.svelte';
import VoteCount from '../../components/content/VoteCount.svelte'; import VoteCount from '../../components/content/VoteCount.svelte';
import DiscussionVoteButtons from './DiscussionVoteButtons.svelte'; import DiscussionVoteButtons from './DiscussionVoteButtons.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
@ -27,9 +27,10 @@
downvotes?: number; // Pre-calculated downvote count from batch fetch downvotes?: number; // Pre-calculated downvote count from batch fetch
votesCalculated?: boolean; // Whether vote counts are ready to display votesCalculated?: boolean; // Whether vote counts are ready to display
fullView?: boolean; // If true, show full markdown content instead of preview fullView?: boolean; // If true, show full markdown content instead of preview
preloadedReactions?: NostrEvent[]; // Pre-loaded reactions from batch fetch
} }
let { thread, commentCount: providedCommentCount = 0, upvotes: providedUpvotes = 0, downvotes: providedDownvotes = 0, votesCalculated: providedVotesCalculated = false, fullView = false }: Props = $props(); let { thread, commentCount: providedCommentCount = 0, upvotes: providedUpvotes = 0, downvotes: providedDownvotes = 0, votesCalculated: providedVotesCalculated = false, fullView = false, preloadedReactions = [] }: Props = $props();
// Media kinds that should auto-render media (except on /feed) // Media kinds that should auto-render media (except on /feed)
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY]; const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY];
@ -190,67 +191,29 @@
{#if !fullView} {#if !fullView}
<a href="/event/{thread.id}" class="card-link"> <a href="/event/{thread.id}" class="card-link">
<div class="card-content" class:expanded={expanded} bind:this={contentElement}> <div class="card-content" class:expanded={expanded} bind:this={contentElement}>
<div class="flex justify-between items-start mb-2 gap-2"> {#if getTitle() && getTitle() !== 'Untitled'}
<h3 class="font-semibold text-fog-text dark:text-fog-dark-text flex-1 min-w-0 overflow-hidden"> <h2 class="post-title font-bold mb-4 text-fog-text dark:text-fog-dark-text overflow-hidden" style="font-size: 1.5em;">
{getTitle()} {getTitle()}
</h3> </h2>
<div class="flex items-center gap-2 flex-shrink-0"> {/if}
<span class="text-fog-text-light dark:text-fog-dark-text-light whitespace-nowrap" style="font-size: 0.875em;">{getRelativeTime()}</span>
<IconButton
icon="eye"
label="View"
size={16}
onclick={() => goto(getEventLink(thread))}
/>
{#if isLoggedIn}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => showReplyForm = !showReplyForm}
/>
{/if}
<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>
<div class="mb-2 flex items-center gap-2"> <CardHeader
<div class="interactive-element"> pubkey={thread.pubkey}
<ProfileBadge pubkey={thread.pubkey} /> relativeTime={getRelativeTime()}
</div> clientName={getClientName()}
{#if getClientName()} topics={getTopics().length > 0 ? getTopics() : undefined}
<span class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.75em;">via {getClientName()}</span> showDivider={true}
{/if} />
</div>
<!-- Display metadata (title, author, summary, description, image) --> <!-- Display metadata (title, author, summary, description, image) -->
<MetadataCard event={thread} hideTitle={true} hideImageIfInMedia={true} /> <MetadataCard event={thread} hideTitle={true} hideImageIfInMedia={true} />
{#if shouldAutoRenderMedia} <div class="post-content mb-2">
<MediaAttachments event={thread} forceRender={isMediaKind} /> {#if shouldAutoRenderMedia}
{/if} <MediaAttachments event={thread} forceRender={isMediaKind} />
<p class="mb-2 text-fog-text dark:text-fog-dark-text">{getPreview()}</p>
{#if getTopics().length > 0}
<div class="flex gap-2 topic-tags">
{#each getTopics() as topic}
<span class="text-xs bg-fog-highlight dark:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text px-2 py-1 rounded">{topic}</span>
{/each}
</div>
{/if} {/if}
<MarkdownRenderer content={thread.content} event={thread} />
</div>
</div> </div>
</a> </a>
{:else} {:else}
@ -277,14 +240,10 @@
</div> </div>
</div> </div>
<div class="mb-2 flex items-center gap-2"> <CardHeader
<div class="interactive-element"> pubkey={thread.pubkey}
<ProfileBadge pubkey={thread.pubkey} /> clientName={getClientName()}
</div> />
{#if getClientName()}
<span class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
</div>
<!-- Display metadata (title, author, summary, description, image) --> <!-- Display metadata (title, author, summary, description, image) -->
<MetadataCard event={thread} hideTitle={true} hideImageIfInMedia={true} /> <MetadataCard event={thread} hideTitle={true} hideImageIfInMedia={true} />
@ -315,12 +274,12 @@
{/if} {/if}
<!-- Card footer (stats) - always visible, outside collapsible content --> <!-- 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 mt-2" style="font-size: 0.75em;"> <div class="flex items-center justify-between text-fog-text dark:text-fog-dark-text thread-stats mt-2 flex-wrap gap-2" style="font-size: 0.75em;">
<div class="flex items-center gap-4 flex-wrap"> <div class="flex items-center gap-4 flex-wrap">
<DiscussionVoteButtons event={thread} /> <DiscussionVoteButtons event={thread} preloadedReactions={preloadedReactions} />
{#if !fullView} {#if !fullView}
{#if loadingStats} {#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span> <span class="text-fog-text-light dark:text-fog-dark-text-light">Loading...</span>
{:else} {:else}
<span class="font-medium vote-comment-spacing">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span> <span class="font-medium vote-comment-spacing">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span>
{#if latestResponseTime} {#if latestResponseTime}
@ -329,7 +288,7 @@
{/if} {/if}
{:else} {:else}
{#if loadingStats} {#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span> <span class="text-fog-text-light dark:text-fog-dark-text-light">Loading...</span>
{:else} {:else}
{#if latestResponseTime} {#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span> <span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
@ -337,11 +296,10 @@
{/if} {/if}
{/if} {/if}
</div> </div>
</div> <div class="kind-badge">
<span class="kind-number">{getKindInfo(thread.kind).number}</span>
<div class="kind-badge"> <span class="kind-description">{getKindInfo(thread.kind).description}</span>
<span class="kind-number">{getKindInfo(thread.kind).number}</span> </div>
<span class="kind-description">{getKindInfo(thread.kind).description}</span>
</div> </div>
</article> </article>
@ -394,23 +352,6 @@
background: var(--fog-dark-highlight, #475569); background: var(--fog-dark-highlight, #475569);
} }
.interactive-element {
position: relative;
z-index: 10;
pointer-events: auto;
}
.card-link {
pointer-events: auto;
}
.card-link > * {
pointer-events: none;
}
.card-link .interactive-element {
pointer-events: auto;
}
.thread-card a { .thread-card a {
color: inherit; color: inherit;
@ -433,6 +374,17 @@
max-height: none; max-height: none;
} }
.post-content {
line-height: 1.6;
}
.post-content :global(img) {
max-width: 600px;
height: auto;
border-radius: 0.5rem;
margin: 0.5rem 0;
}
.show-more-button { .show-more-button {
width: 100%; width: 100%;
text-align: center; text-align: center;
@ -443,9 +395,6 @@
} }
.kind-badge { .kind-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@ -453,29 +402,12 @@
font-size: 0.625rem; font-size: 0.625rem;
line-height: 1; line-height: 1;
color: var(--fog-text-light, #52667a); color: var(--fog-text-light, #52667a);
flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
} }
.topic-tags {
margin-bottom: 1rem; /* Increased space between topic tags and count row */
}
@media (max-width: 640px) { @media (max-width: 640px) {
.topic-tags { .post-title {
margin-bottom: 1.25rem; /* Even more space on narrow screens */
}
.kind-badge {
position: static;
margin-top: 0.25rem; /* Decreased space between count row and kind badge */
justify-content: flex-end;
}
.thread-stats {
margin-bottom: 0.25rem; /* Decreased space between count row and kind badge */
}
h3 {
word-break: break-word; word-break: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
@ -495,8 +427,6 @@
} }
.thread-stats { .thread-stats {
padding-right: 0;
margin-bottom: 1.5rem; /* Space for kind badge below */
padding-top: 0.5rem; padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb); border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem; margin-top: 0.5rem;

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

@ -6,7 +6,7 @@
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { KIND } from '../../types/kind-lookup.js'; import { KIND } from '../../types/kind-lookup.js';
import { getRecentCachedEvents } from '../../services/cache/event-cache.js'; import { getRecentCachedEvents, getCachedReactionsForEvents } from '../../services/cache/event-cache.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -46,7 +46,7 @@
// Data maps - threads and stats for sorting only (DiscussionCard loads its own stats for display) // 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 let threadsMap = $state<Map<string, NostrEvent>>(new Map()); // threadId -> thread
let reactionsMap = $state<Map<string, NostrEvent[]>>(new Map()); // threadId -> reactions[] (for sorting only) let reactionsMap = $state<Map<string, NostrEvent[]>>(new Map()); // threadId -> reactions[] (for sorting and display)
let commentsMap = $state<Map<string, number>>(new Map()); // threadId -> commentCount (batch-loaded for display) let commentsMap = $state<Map<string, number>>(new Map()); // threadId -> commentCount (batch-loaded for display)
let voteCountsMap = $state<Map<string, { upvotes: number; downvotes: number }>>(new Map()); // threadId -> {upvotes, downvotes} (calculated from reactionsMap) let voteCountsMap = $state<Map<string, { upvotes: number; downvotes: number }>>(new Map()); // threadId -> {upvotes, downvotes} (calculated from reactionsMap)
let voteCountsReady = $state(false); // Track when vote counts are fully calculated let voteCountsReady = $state(false); // Track when vote counts are fully calculated
@ -227,10 +227,7 @@
const threadIds = Array.from(threadsMap.keys()); const threadIds = Array.from(threadsMap.keys());
if (threadIds.length > 0) { if (threadIds.length > 0) {
// Load reactions for sorting purposes only // Load reactions for sorting and display - optimized batch loading
// DiscussionCard components will load their own stats for display
// Fetch all reactions in parallel (for sorting)
const allReactionsMap = new Map<string, NostrEvent>(); const allReactionsMap = new Map<string, NostrEvent>();
const processReactionUpdates = async () => { const processReactionUpdates = async () => {
@ -238,12 +235,15 @@
if (allReactions.length === 0) return; if (allReactions.length === 0) return;
if (!isMounted) return; if (!isMounted) return;
// Fetch deletion events for specific reaction IDs only // Fetch deletion events for specific reaction IDs only (batch)
const reactionIds = allReactions.map(r => r.id); const reactionIds = allReactions.map(r => r.id);
// Limit to first 200 to avoid massive queries
const limitedReactionIds = reactionIds.slice(0, 200);
const deletionFetchPromise = nostrClient.fetchEvents( const deletionFetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.EVENT_DELETION], '#e': reactionIds, limit: config.feedLimit }], [{ kinds: [KIND.EVENT_DELETION], '#e': limitedReactionIds, limit: config.feedLimit }],
reactionRelays, reactionRelays,
{ useCache: 'relay-first', cacheResults: true, timeout: config.standardTimeout } { useCache: 'cache-first', cacheResults: true, timeout: config.shortTimeout, priority: 'low' }
); );
activeFetchPromises.add(deletionFetchPromise); activeFetchPromises.add(deletionFetchPromise);
const deletionEvents = await deletionFetchPromise; const deletionEvents = await deletionFetchPromise;
@ -261,7 +261,7 @@
} }
} }
// Rebuild reactions map (for sorting only) // Rebuild reactions map (for sorting and display)
const updatedReactionsMap = new Map<string, NostrEvent[]>(); const updatedReactionsMap = new Map<string, NostrEvent[]>();
for (const reaction of allReactions) { for (const reaction of allReactions) {
if (deletedReactionIds.has(reaction.id)) continue; if (deletedReactionIds.has(reaction.id)) continue;
@ -281,7 +281,8 @@
if (isMounted) { if (isMounted) {
reactionsMap = updatedReactionsMap; reactionsMap = updatedReactionsMap;
// Don't update vote counts during real-time updates - only show after initial load updateVoteCountsMap();
voteCountsReady = true;
} }
}; };
@ -295,8 +296,23 @@
if (!isMounted) return; if (!isMounted) return;
// Optimized: Fetch reactions with both #e and #E in single call (most relays support both) // Step 1: Load from cache first (instant)
// If a relay rejects #E, it will just return empty results for that filter const cachedReactionsMap = await getCachedReactionsForEvents(threadIds);
// Add cached reactions immediately
for (const [eventId, reactions] of cachedReactionsMap) {
for (const reaction of reactions) {
allReactionsMap.set(reaction.id, reaction);
}
}
// Process cached reactions immediately for instant display
if (allReactionsMap.size > 0) {
await processReactionUpdates();
}
// Step 2: Fetch from relays in parallel (fast, non-blocking)
// Use shorter timeout for faster updates
const reactionsFetchPromise = nostrClient.fetchEvents( const reactionsFetchPromise = nostrClient.fetchEvents(
[ [
{ kinds: [KIND.REACTION], '#e': threadIds, limit: config.feedLimit }, { kinds: [KIND.REACTION], '#e': threadIds, limit: config.feedLimit },
@ -304,29 +320,31 @@
], ],
reactionRelays, reactionRelays,
{ {
useCache: 'cache-first', // Changed from relay-first for better performance useCache: 'cache-first', // Load from cache first, then update from relays
cacheResults: true, cacheResults: true,
timeout: config.standardTimeout, timeout: config.shortTimeout, // Faster timeout for reactions
onUpdate: handleReactionUpdate onUpdate: handleReactionUpdate,
priority: 'low' // Low priority - don't block other requests
} }
); );
activeFetchPromises.add(reactionsFetchPromise); activeFetchPromises.add(reactionsFetchPromise);
const allReactions = await reactionsFetchPromise;
activeFetchPromises.delete(reactionsFetchPromise);
if (!isMounted) return;
// Add all reactions to map (deduplication handled by Map)
for (const r of allReactions) {
allReactionsMap.set(r.id, r);
}
// Process reactions // Don't await - let it update in background
await processReactionUpdates(); reactionsFetchPromise.then((allReactions) => {
if (!isMounted) return;
// Calculate vote counts from reactions for preview cards (only after initial load is complete)
updateVoteCountsMap(); // Add all new reactions to map (deduplication handled by Map)
voteCountsReady = true; for (const r of allReactions) {
allReactionsMap.set(r.id, r);
}
// Process and update counts
processReactionUpdates();
}).catch(() => {
// Silently fail - reactions are non-critical
}).finally(() => {
activeFetchPromises.delete(reactionsFetchPromise);
});
// Fetch comments // Fetch comments
if (!isMounted) return; if (!isMounted) return;
@ -623,6 +641,7 @@
upvotes={voteCounts.upvotes} upvotes={voteCounts.upvotes}
downvotes={voteCounts.downvotes} downvotes={voteCounts.downvotes}
votesCalculated={voteCountsReady} votesCalculated={voteCountsReady}
preloadedReactions={reactionsMap.get(thread.id) ?? []}
/> />
</div> </div>
{/each} {/each}

109
src/lib/modules/discussions/DiscussionVoteButtons.svelte

@ -8,6 +8,7 @@
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js'; import { KIND } from '../../types/kind-lookup.js';
import VoteCount from '../../components/content/VoteCount.svelte'; import VoteCount from '../../components/content/VoteCount.svelte';
import { getCachedReactionsForEvents } from '../../services/cache/event-cache.js';
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
@ -61,44 +62,79 @@
} }
isMounted = true; isMounted = true;
nostrClient.initialize().then(async () => { // Load from cache IMMEDIATELY (no waiting for initialization)
if (event.id) { if (event.id) {
// Use pre-loaded reactions if available, otherwise fetch // Use pre-loaded reactions if available
if (preloadedReactions && preloadedReactions.length > 0) { if (preloadedReactions && preloadedReactions.length > 0) {
try { // Process preloaded reactions immediately
const filtered = await filterDeletedReactions(preloadedReactions); filterDeletedReactions(preloadedReactions).then(filtered => {
const filteredMap = new Map<string, NostrEvent>();
// Update the map to only contain non-deleted reactions for (const reaction of filtered) {
// Reassign map to trigger reactivity in Svelte 5 filteredMap.set(reaction.id, reaction);
const filteredMap = new Map<string, NostrEvent>();
for (const reaction of filtered) {
filteredMap.set(reaction.id, reaction);
}
allReactionsMap = filteredMap;
processReactions(filtered);
initialLoadComplete = true; // Mark initial load as complete
} catch (error) {
console.error('[DiscussionVoteButtons] Error processing preloaded reactions:', error);
initialLoadComplete = true; // Mark as complete even on error
} finally {
loading = false; // Always set loading to false
} }
} else { allReactionsMap = filteredMap;
loadReactions(); processReactions(filtered);
} initialLoadComplete = true;
loading = false;
}).catch(() => {
initialLoadComplete = true;
loading = false;
});
} else { } else {
// No event ID, mark as complete // Load from cache immediately
loading = false; loadFromCache();
initialLoadComplete = true;
} }
}).catch((error) => { } else {
console.error('[DiscussionVoteButtons] Error initializing client:', error);
loading = false; loading = false;
initialLoadComplete = true; // Mark as complete even on error initialLoadComplete = true;
}
// Initialize client in background for relay fetching
nostrClient.initialize().catch(() => {
// Silently fail - cache is already loaded
}); });
}); });
// Load reactions from cache immediately (synchronous cache access)
async function loadFromCache() {
if (!event.id) return;
try {
// Load from cache FIRST - instant display
const cachedReactionsMap = await getCachedReactionsForEvents([event.id]);
const cachedReactions = cachedReactionsMap.get(event.id) || [];
if (cachedReactions.length > 0) {
// Process cached reactions immediately
const filtered = await filterDeletedReactions(cachedReactions);
const filteredMap = new Map<string, NostrEvent>();
for (const reaction of filtered) {
filteredMap.set(reaction.id, reaction);
}
allReactionsMap = filteredMap;
processReactions(filtered);
initialLoadComplete = true;
loading = false; // Show cached results immediately
} else {
// No cache, need to fetch
loading = false; // Don't show loading spinner if we have no cache
initialLoadComplete = true;
}
// Fetch from relays in background (non-blocking)
loadReactions().catch(() => {
// Silently fail - we already have cache
});
} catch (error) {
// Cache load failed, try fetching
loading = false;
initialLoadComplete = true;
loadReactions().catch(() => {
// Silently fail
});
}
}
// Reload reactions when event changes (but prevent duplicate loads and initial mount) // Reload reactions when event changes (but prevent duplicate loads and initial mount)
$effect(() => { $effect(() => {
// Only run after mount and when event.id actually changes // Only run after mount and when event.id actually changes
@ -204,6 +240,12 @@
} }
async function loadReactions() { async function loadReactions() {
// Skip loading if we already have preloaded reactions
if (preloadedReactions && preloadedReactions.length > 0) {
// Preloaded reactions are already processed in onMount/$effect
return;
}
// Prevent concurrent loads for the same event // Prevent concurrent loads for the same event
if (loadingReactions) { if (loadingReactions) {
// If already loading, ensure loading state is set correctly // If already loading, ensure loading state is set correctly
@ -223,15 +265,16 @@
allReactionsMap = new Map(); // Reassign to trigger reactivity allReactionsMap = new Map(); // Reassign to trigger reactivity
// Use low priority for reactions - they're background data, comments should load first // Use low priority for reactions - they're background data, comments should load first
// Use cache-first and shorter timeout for faster loading
const reactionsWithLowerE = await nostrClient.fetchEvents( const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': [event.id], limit: config.feedLimit }], [{ kinds: [KIND.REACTION], '#e': [event.id], limit: config.feedLimit }],
reactionRelays, reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.mediumTimeout, priority: 'low' } { useCache: 'cache-first', cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.shortTimeout, priority: 'low' }
); );
const reactionsWithUpperE = await nostrClient.fetchEvents( const reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#E': [event.id], limit: config.feedLimit }], [{ kinds: [KIND.REACTION], '#E': [event.id], limit: config.feedLimit }],
reactionRelays, reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.mediumTimeout, priority: 'low' } { useCache: 'cache-first', cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.shortTimeout, priority: 'low' }
); );
// Combine and deduplicate by reaction ID // Combine and deduplicate by reaction ID

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

@ -6,7 +6,7 @@
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js'; import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js';
import { getRecentFeedEvents } from '../../services/cache/event-cache.js'; import { getRecentFeedEvents, getCachedReactionsForEvents } from '../../services/cache/event-cache.js';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js'; import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
@ -43,6 +43,9 @@
// Preloaded referenced events (e, a, q tags) - eventId -> referenced event // Preloaded referenced events (e, a, q tags) - eventId -> referenced event
let preloadedReferencedEvents = $state<Map<string, NostrEvent>>(new Map()); let preloadedReferencedEvents = $state<Map<string, NostrEvent>>(new Map());
// Preloaded reactions for kind 11 events - eventId -> reactions[]
let preloadedReactionsMap = $state<Map<string, NostrEvent[]>>(new Map());
// Virtual scrolling for performance // Virtual scrolling for performance
let Virtualizer: any = $state(null); let Virtualizer: any = $state(null);
let virtualizerLoading = $state(false); let virtualizerLoading = $state(false);
@ -330,6 +333,8 @@
// Batch fetch referenced events (e, a, q tags) after main events are loaded // Batch fetch referenced events (e, a, q tags) after main events are loaded
if (allEvents.length > 0) { if (allEvents.length > 0) {
await batchFetchReferencedEvents(allEvents); await batchFetchReferencedEvents(allEvents);
// Batch load reactions for kind 11 events
await batchLoadReactionsForKind11(allEvents);
} }
} catch (error) { } catch (error) {
// Failed to load feed // Failed to load feed
@ -485,6 +490,98 @@
} }
} }
// Batch load reactions for kind 11 events (optimized cache-first loading)
async function batchLoadReactionsForKind11(events: NostrEvent[]) {
if (!isMounted || events.length === 0) return;
// Filter to only kind 11 events
const kind11Events = events.filter(e => e.kind === KIND.DISCUSSION_THREAD);
if (kind11Events.length === 0) return;
const eventIds = kind11Events.map(e => e.id);
const reactionRelays = relayManager.getProfileReadRelays();
const allReactionsMap = new Map<string, NostrEvent>();
try {
// Step 1: Load from cache first (instant)
const cachedReactionsMap = await getCachedReactionsForEvents(eventIds);
// Add cached reactions immediately
for (const [eventId, reactions] of cachedReactionsMap) {
for (const reaction of reactions) {
allReactionsMap.set(reaction.id, reaction);
}
}
// Process cached reactions and update preloaded map
const reactionsByEvent = new Map<string, NostrEvent[]>();
for (const [eventId, reactions] of cachedReactionsMap) {
reactionsByEvent.set(eventId, reactions);
}
// Update preloaded reactions map with cached data
if (reactionsByEvent.size > 0 && isMounted) {
const merged = new Map(preloadedReactionsMap);
for (const [eventId, reactions] of reactionsByEvent.entries()) {
merged.set(eventId, reactions);
}
preloadedReactionsMap = merged;
}
// Step 2: Fetch from relays in parallel (fast, non-blocking)
const reactionsFetchPromise = nostrClient.fetchEvents(
[
{ kinds: [KIND.REACTION], '#e': eventIds, limit: config.feedLimit },
{ kinds: [KIND.REACTION], '#E': eventIds, limit: config.feedLimit }
],
reactionRelays,
{
useCache: 'cache-first',
cacheResults: true,
timeout: config.shortTimeout,
priority: 'low'
}
);
// Don't await - let it update in background
reactionsFetchPromise.then((allReactions) => {
if (!isMounted) return;
// Group reactions by event ID
const updatedReactionsByEvent = new Map<string, NostrEvent[]>();
for (const reaction of allReactions) {
// Find the event ID this reaction references
const eventIdTag = reaction.tags.find(t => {
const tagName = t[0];
return (tagName === 'e' || tagName === 'E') && t[1] && eventIds.includes(t[1]);
});
if (eventIdTag && eventIdTag[1]) {
const eventId = eventIdTag[1];
if (!updatedReactionsByEvent.has(eventId)) {
updatedReactionsByEvent.set(eventId, []);
}
updatedReactionsByEvent.get(eventId)!.push(reaction);
}
}
// Update preloaded reactions map
if (updatedReactionsByEvent.size > 0 && isMounted) {
const merged = new Map(preloadedReactionsMap);
for (const [eventId, reactions] of updatedReactionsByEvent.entries()) {
merged.set(eventId, reactions);
}
preloadedReactionsMap = merged;
}
}).catch(() => {
// Silently fail - reactions are non-critical
});
} catch (error) {
// Failed to load reactions (non-critical)
}
}
// Setup subscription (only adds to waiting room) // Setup subscription (only adds to waiting room)
function setupSubscription() { function setupSubscription() {
if (subscriptionId || singleRelay) return; if (subscriptionId || singleRelay) return;
@ -626,7 +723,8 @@
<div class="feed-posts"> <div class="feed-posts">
{#each events as event (event.id)} {#each events as event (event.id)}
{@const referencedEvent = getReferencedEventForPost(event)} {@const referencedEvent = getReferencedEventForPost(event)}
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} /> {@const preloadedReactions = preloadedReactionsMap.get(event.id) ?? []}
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} preloadedReactions={preloadedReactions} />
{/each} {/each}
</div> </div>
{:else} {:else}
@ -634,7 +732,8 @@
<div class="feed-posts"> <div class="feed-posts">
{#each events as event (event.id)} {#each events as event (event.id)}
{@const referencedEvent = getReferencedEventForPost(event)} {@const referencedEvent = getReferencedEventForPost(event)}
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} /> {@const preloadedReactions = preloadedReactionsMap.get(event.id) ?? []}
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} preloadedReactions={preloadedReactions} />
{/each} {/each}
</div> </div>
{/if} {/if}

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

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import CardHeader from '../../components/layout/CardHeader.svelte';
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import EventMenu from '../../components/EventMenu.svelte'; import EventMenu from '../../components/EventMenu.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
@ -73,6 +74,7 @@
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY]; const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY];
const isMediaKind = $derived(MEDIA_KINDS.includes(post.kind)); const isMediaKind = $derived(MEDIA_KINDS.includes(post.kind));
const isOnFeedPage = $derived($page.url.pathname === '/feed'); const isOnFeedPage = $derived($page.url.pathname === '/feed');
const isOnEventPage = $derived($page.url.pathname.startsWith('/event/'));
const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage); const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage);
// Check if card should be collapsed (only in feed view) // Check if card should be collapsed (only in feed view)
@ -838,27 +840,14 @@
</h2> </h2>
{/if} {/if}
<div class="post-header flex items-center justify-between gap-2 mb-2"> <CardHeader
<div class="flex items-center gap-2 flex-1 min-w-0 post-header-left"> pubkey={post.pubkey}
<div class="flex-shrink-1 min-w-0 max-w-full profile-badge-wrapper"> relativeTime={getRelativeTime()}
<ProfileBadge pubkey={post.pubkey} /> clientName={getClientName()}
</div> topics={post.kind === KIND.DISCUSSION_THREAD && !isOnEventPage ? (getTopics().length === 0 ? ['General'] : getTopics()) : undefined}
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">{getRelativeTime()}</span> showDivider={post.kind === KIND.DISCUSSION_THREAD && !isOnEventPage}
{#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> {#snippet actions()}
{/if}
{#if post.kind === KIND.DISCUSSION_THREAD}
{@const topics = getTopics()}
{#if topics.length === 0}
<a href="/topics/General" class="topic-badge px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light hover:underline" style="font-size: 0.75em;">General</a>
{:else}
{#each topics.slice(0, 3) as topic}
<a href="/topics/{topic}" class="topic-badge px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light hover:underline" style="font-size: 0.75em;">{topic}</a>
{/each}
{/if}
{/if}
</div>
<div class="post-header-actions flex items-center gap-2 flex-shrink-0">
{#if isLoggedIn && bookmarked} {#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span> <span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if} {/if}
@ -877,9 +866,8 @@
/> />
{/if} {/if}
<EventMenu event={post} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} /> <EventMenu event={post} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} />
</div> {/snippet}
<hr class="post-header-divider" /> </CardHeader>
</div>
<div class="post-content mb-2"> <div class="post-content mb-2">
{#if (shouldAutoRenderMedia || fullView) && (post.content && post.content.trim())} {#if (shouldAutoRenderMedia || fullView) && (post.content && post.content.trim())}
@ -893,33 +881,46 @@
{/if} {/if}
</div> </div>
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4"> <div class="post-actions flex flex-wrap items-center justify-between gap-2 sm:gap-4">
<FeedReactionButtons event={post} preloadedReactions={preloadedReactions} /> <div class="post-actions-left flex items-center gap-2 sm:gap-4">
<FeedReactionButtons event={post} preloadedReactions={preloadedReactions} />
</div>
<div class="kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
</div> </div>
{:else} {:else}
<!-- Feed view: plaintext only, no profile pics, media as URLs --> <!-- Feed view: plaintext only, no profile pics, media as URLs -->
<div class="post-header flex items-center justify-between gap-2 mb-2"> <CardHeader
<div class="flex items-center gap-2 flex-1 min-w-0 post-header-left"> pubkey={post.pubkey}
<div class="flex-shrink-0"> relativeTime={getRelativeTime()}
<ProfileBadge pubkey={post.pubkey} inline={true} /> clientName={getClientName()}
</div> topics={post.kind === KIND.DISCUSSION_THREAD ? (getTopics().length === 0 ? ['General'] : getTopics()) : undefined}
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">{getRelativeTime()}</span> inline={true}
{#if getClientName()} showDivider={true}
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">via {getClientName()}</span> >
{#snippet actions()}
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if} {/if}
{#if post.kind === KIND.DISCUSSION_THREAD} <IconButton
{@const topics = getTopics()} icon="eye"
{#if topics.length === 0} label="View"
<a href="/topics/General" class="topic-badge px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light hover:underline" style="font-size: 0.75em;">General</a> size={16}
{:else} onclick={() => goto(getEventLink(post))}
{#each topics.slice(0, 3) as topic} />
<a href="/topics/{topic}" class="topic-badge px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light hover:underline" style="font-size: 0.75em;">{topic}</a> {#if isLoggedIn}
{/each} <IconButton
{/if} icon="message-square"
label="Reply"
size={16}
onclick={() => showReplyForm = !showReplyForm}
/>
{/if} {/if}
</div> <EventMenu event={post} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} />
<hr class="post-header-divider" /> {/snippet}
</div> </CardHeader>
{@const title = getTitle()} {@const title = getTitle()}
{#if title && title !== 'Untitled'} {#if title && title !== 'Untitled'}
@ -1044,7 +1045,7 @@
<FeedReactionButtons event={post} preloadedReactions={preloadedReactions} /> <FeedReactionButtons event={post} preloadedReactions={preloadedReactions} />
</div> </div>
</div> </div>
<div class="feed-card-footer flex items-center justify-between"> <div class="feed-card-footer flex items-center justify-between flex-wrap gap-2">
<div class="feed-card-actions flex items-center gap-2"> <div class="feed-card-actions flex items-center gap-2">
{#if isLoggedIn && bookmarked} {#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span> <span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
@ -1073,12 +1074,6 @@
{/if} {/if}
{/if} {/if}
{#if fullView}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
{/if}
</article> </article>
</div> </div>
@ -1246,11 +1241,15 @@
.post-actions { .post-actions {
padding-top: 0.5rem; padding-top: 0.5rem;
padding-right: 6rem;
border-top: 1px solid var(--fog-border, #e5e7eb); border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.post-actions-left {
flex: 1;
min-width: 0;
}
:global(.dark) .post-actions { :global(.dark) .post-actions {
border-top-color: var(--fog-dark-border, #374151); border-top-color: var(--fog-dark-border, #374151);
} }
@ -1287,9 +1286,6 @@
} }
.kind-badge { .kind-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@ -1297,10 +1293,13 @@
font-size: 0.625rem; font-size: 0.625rem;
line-height: 1; line-height: 1;
color: var(--fog-text-light, #52667a); color: var(--fog-text-light, #52667a);
flex-shrink: 0;
white-space: nowrap;
} }
.feed-card-kind-badge { .feed-card-kind-badge {
position: static; flex-shrink: 0;
white-space: nowrap;
} }
:global(.dark) .kind-badge { :global(.dark) .kind-badge {
@ -1321,178 +1320,12 @@
} }
.post-header {
display: flex;
align-items: center;
line-height: 1.5;
position: relative;
gap: 0.5rem;
min-width: 0;
flex-wrap: wrap;
width: 100%;
max-width: 100%;
box-sizing: border-box;
overflow: hidden;
word-break: break-word;
overflow-wrap: anywhere;
}
.post-header-left {
min-width: 0;
overflow: hidden;
width: 100%;
max-width: 100%;
box-sizing: border-box;
word-break: break-word;
overflow-wrap: anywhere;
}
.post-header-left > span {
word-break: break-word !important;
overflow-wrap: anywhere !important;
white-space: normal !important;
max-width: 100%;
}
.profile-badge-wrapper {
min-width: 0 !important;
max-width: 100% !important;
flex-shrink: 1 !important;
overflow: visible !important;
}
.post-header-left :global(.profile-badge) {
max-width: 100% !important;
width: auto !important;
min-width: 0 !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
box-sizing: border-box !important;
flex-shrink: 1 !important;
flex-wrap: wrap !important;
}
/* Apply break-all only on wider screens (not in narrow screen media query) */
@media (min-width: 641px) {
.post-header-left :global(.nip05-text),
.post-header-left :global(.break-nip05) {
word-break: break-all !important;
overflow-wrap: anywhere !important;
word-wrap: break-word !important;
white-space: normal !important;
max-width: 100% !important;
width: 100% !important;
display: block !important;
box-sizing: border-box !important;
}
}
@media (max-width: 640px) {
.post-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.post-header-left {
width: 100%;
flex-wrap: wrap;
gap: 0.5rem;
}
.post-header-left > span {
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
max-width: 100%;
flex-shrink: 1;
min-width: 0;
}
.post-header-left {
width: 100%;
flex-wrap: wrap;
gap: 0.5rem;
}
.post-header-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.profile-badge-wrapper {
max-width: 100% !important;
width: 100% !important;
flex-shrink: 1 !important;
min-width: 0 !important;
}
.post-header-left :global(.profile-badge) {
max-width: 100% !important;
width: 100% !important;
min-width: 0 !important;
flex-shrink: 1 !important;
}
.post-header-left :global(.nip05-container) {
flex-direction: column !important;
align-items: flex-start !important;
width: 100% !important;
}
/* Truncate NIP-05 to 10 characters on narrow screens */
/* Override both the class selectors and Tailwind's break-all utility */
.post-header-left :global(.nip05-text),
.post-header-left :global(.break-nip05),
.post-header-left :global(.nip05-text.break-all),
.post-header-left :global(.break-nip05.break-all),
.post-header-left :global(span.nip05-text),
.post-header-left :global(span.break-nip05),
.post-header-left :global(span.nip05-text.break-all),
.post-header-left :global(span.break-nip05.break-all) {
max-width: 10ch !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
word-break: normal !important;
overflow-wrap: normal !important;
word-wrap: normal !important;
display: inline-block !important;
width: auto !important;
box-sizing: border-box !important;
}
}
.post-header-divider {
position: absolute;
bottom: -0.5rem;
left: 0;
right: 0;
margin: 0;
border: none;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
.post-header-actions {
flex-shrink: 0;
display: flex !important;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
visibility: visible !important;
}
.feed-card-actions { .feed-card-actions {
display: flex !important; display: flex !important;
visibility: visible !important; visibility: visible !important;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.post-header-actions {
gap: 0.375rem;
}
.feed-card-actions { .feed-card-actions {
gap: 0.375rem; gap: 0.375rem;
flex-wrap: wrap; flex-wrap: wrap;
@ -1505,26 +1338,6 @@
} }
} }
:global(.dark) .post-header-divider {
border-top-color: var(--fog-dark-border, #374151);
}
.post-header :global(.profile-badge) {
display: inline-flex;
align-items: center;
vertical-align: middle;
line-height: 1.5;
width: auto;
max-width: none;
}
.post-header :global(.profile-badge span) {
line-height: 1.5;
vertical-align: middle;
}
.bookmark-indicator { .bookmark-indicator {
display: inline-block; display: inline-block;
font-size: 1rem; font-size: 1rem;

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

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import CardHeader from '../../components/layout/CardHeader.svelte';
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import EventMenu from '../../components/EventMenu.svelte'; import EventMenu from '../../components/EventMenu.svelte';
@ -313,41 +314,40 @@
class:cursor-pointer={!!(sourceEvent || parseATag())} class:cursor-pointer={!!(sourceEvent || parseATag())}
{...((sourceEvent || parseATag()) ? { role: "button", tabindex: 0 } : {})} {...((sourceEvent || parseATag()) ? { role: "button", tabindex: 0 } : {})}
> >
<div class="highlight-header"> <CardHeader
<div class="highlight-meta"> pubkey={highlight.pubkey}
<ProfileBadge pubkey={highlight.pubkey} /> relativeTime={getRelativeTime()}
clientName={getClientName()}
>
{#snippet left()}
{#if getAuthorPubkey() && getAuthorPubkey() !== highlight.pubkey} {#if getAuthorPubkey() && getAuthorPubkey() !== highlight.pubkey}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">highlighting</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">highlighting</span>
<ProfileBadge pubkey={getAuthorPubkey()!} /> <ProfileBadge pubkey={getAuthorPubkey()!} inline={true} />
{/if} {/if}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{getRelativeTime()}</span> {/snippet}
{#if getClientName()} {#snippet actions()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span> {#if isLoggedIn && bookmarked}
<span title="Bookmarked">
<Icon name="bookmark" size={16} class="bookmark-indicator bookmarked" />
</span>
{/if} {/if}
<div class="ml-auto flex items-center gap-2"> <IconButton
{#if isLoggedIn && bookmarked} icon="eye"
<span title="Bookmarked"> label="View"
<Icon name="bookmark" size={16} class="bookmark-indicator bookmarked" /> size={16}
</span> onclick={() => goto(getEventLink(highlight))}
{/if} />
{#if isLoggedIn}
<IconButton <IconButton
icon="eye" icon="message-square"
label="View" label="Reply"
size={16} size={16}
onclick={() => goto(getEventLink(highlight))} onclick={() => {}}
/> />
{#if isLoggedIn} {/if}
<IconButton <EventMenu event={highlight} showContentActions={true} onReply={() => {}} />
icon="message-square" {/snippet}
label="Reply" </CardHeader>
size={16}
onclick={() => {}}
/>
{/if}
<EventMenu event={highlight} showContentActions={true} onReply={() => {}} />
</div>
</div>
</div>
<div class="highlight-content"> <div class="highlight-content">
{#if shouldShowContext} {#if shouldShowContext}
@ -408,41 +408,6 @@
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
} }
.highlight-header {
margin-bottom: 0.75rem;
}
.highlight-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
width: 100%;
max-width: 100%;
box-sizing: border-box;
word-break: break-word;
overflow-wrap: anywhere;
}
.highlight-meta :global(.profile-badge) {
max-width: 100% !important;
min-width: 0 !important;
flex-shrink: 1 !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
/* Apply break-all only on wider screens (not in narrow screen media query) */
@media (min-width: 641px) {
.highlight-meta :global(.nip05-text),
.highlight-meta :global(.break-nip05) {
word-break: break-all !important;
overflow-wrap: anywhere !important;
word-wrap: break-word !important;
white-space: normal !important;
max-width: 100% !important;
}
}
.highlight-content { .highlight-content {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
@ -622,37 +587,5 @@
max-width: 100% !important; max-width: 100% !important;
} }
.highlight-meta {
flex-wrap: wrap;
width: 100%;
max-width: 100%;
}
.highlight-meta > span {
word-break: break-word;
overflow-wrap: anywhere;
white-space: normal;
}
/* Truncate NIP-05 to 10 characters on narrow screens */
/* Override both the class selectors and Tailwind's break-all utility */
.highlight-meta :global(.nip05-text),
.highlight-meta :global(.break-nip05),
.highlight-meta :global(.nip05-text.break-all),
.highlight-meta :global(.break-nip05.break-all),
.highlight-meta :global(span.nip05-text),
.highlight-meta :global(span.break-nip05),
.highlight-meta :global(span.nip05-text.break-all),
.highlight-meta :global(span.break-nip05.break-all) {
max-width: 10ch !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
word-break: normal !important;
overflow-wrap: normal !important;
word-wrap: normal !important;
display: inline-block !important;
box-sizing: border-box !important;
}
} }
</style> </style>

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

@ -12,6 +12,7 @@
import emojiData from 'unicode-emoji-json/data-ordered-emoji.json'; import emojiData from 'unicode-emoji-json/data-ordered-emoji.json';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js'; import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
import Icon from '../../components/ui/Icon.svelte'; import Icon from '../../components/ui/Icon.svelte';
import { getCachedReactionsForEvents } from '../../services/cache/event-cache.js';
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
@ -44,22 +45,68 @@
} }
isMounted = true; isMounted = true;
nostrClient.initialize().then(async () => { // Load from cache IMMEDIATELY (no waiting for initialization)
if (event.id) { if (event.id) {
// Use pre-loaded reactions if available, otherwise fetch // Use pre-loaded reactions if available
if (preloadedReactions && preloadedReactions.length > 0) { if (preloadedReactions && preloadedReactions.length > 0) {
for (const r of preloadedReactions) { for (const r of preloadedReactions) {
allReactionsMap.set(r.id, r); allReactionsMap.set(r.id, r);
}
const filtered = await filterDeletedReactions(preloadedReactions);
processReactions(filtered);
} else {
loadReactions();
} }
filterDeletedReactions(preloadedReactions).then(filtered => {
processReactions(filtered);
loading = false;
}).catch(() => {
loading = false;
});
} else {
// Load from cache immediately
loadFromCache();
} }
} else {
loading = false;
}
// Initialize client in background for relay fetching
nostrClient.initialize().catch(() => {
// Silently fail - cache is already loaded
}); });
}); });
// Load reactions from cache immediately (synchronous cache access)
async function loadFromCache() {
if (!event.id) return;
try {
// Load from cache FIRST - instant display
const cachedReactionsMap = await getCachedReactionsForEvents([event.id]);
const cachedReactions = cachedReactionsMap.get(event.id) || [];
if (cachedReactions.length > 0) {
// Process cached reactions immediately
for (const r of cachedReactions) {
allReactionsMap.set(r.id, r);
}
const filtered = await filterDeletedReactions(cachedReactions);
processReactions(filtered);
loading = false; // Show cached results immediately
} else {
// No cache, need to fetch
loading = false; // Don't show loading spinner if we have no cache
}
// Fetch from relays in background (non-blocking)
loadReactions().catch(() => {
// Silently fail - we already have cache
});
} catch (error) {
// Cache load failed, try fetching
loading = false;
loadReactions().catch(() => {
// Silently fail
});
}
}
// Reload reactions when event changes (but prevent duplicate loads and initial mount) // Reload reactions when event changes (but prevent duplicate loads and initial mount)
$effect(() => { $effect(() => {
// Only run after mount and when event.id actually changes // Only run after mount and when event.id actually changes
@ -71,16 +118,20 @@
// Clear previous reactions map when event changes // Clear previous reactions map when event changes
allReactionsMap.clear(); allReactionsMap.clear();
// Use pre-loaded reactions if available, otherwise fetch // Use pre-loaded reactions if available
if (preloadedReactions && preloadedReactions.length > 0) { if (preloadedReactions && preloadedReactions.length > 0) {
for (const r of preloadedReactions) { for (const r of preloadedReactions) {
allReactionsMap.set(r.id, r); allReactionsMap.set(r.id, r);
} }
filterDeletedReactions(preloadedReactions).then(filtered => { filterDeletedReactions(preloadedReactions).then(filtered => {
processReactions(filtered); processReactions(filtered);
loading = false;
}).catch(() => {
loading = false;
}); });
} else { } else {
loadReactions(); // Load from cache immediately, then fetch from relays
loadFromCache();
} }
}); });
@ -128,6 +179,12 @@
} }
async function loadReactions() { async function loadReactions() {
// Skip loading if we already have preloaded reactions
if (preloadedReactions && preloadedReactions.length > 0) {
// Preloaded reactions are already processed in onMount/$effect
return;
}
// Prevent concurrent loads for the same event // Prevent concurrent loads for the same event
if (loadingReactions) { if (loadingReactions) {
return; return;
@ -143,15 +200,16 @@
allReactionsMap.clear(); allReactionsMap.clear();
// Use low priority for reactions - they're background data, comments should load first // Use low priority for reactions - they're background data, comments should load first
// Use cache-first and shorter timeout for faster loading
const reactionsWithLowerE = await nostrClient.fetchEvents( const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': [event.id], limit: config.feedLimit }], [{ kinds: [KIND.REACTION], '#e': [event.id], limit: config.feedLimit }],
reactionRelays, reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.mediumTimeout, priority: 'low' } { useCache: 'cache-first', cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.shortTimeout, priority: 'low' }
); );
const reactionsWithUpperE = await nostrClient.fetchEvents( const reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#E': [event.id], limit: config.feedLimit }], [{ kinds: [KIND.REACTION], '#E': [event.id], limit: config.feedLimit }],
reactionRelays, reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.mediumTimeout, priority: 'low' } { useCache: 'cache-first', cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.shortTimeout, priority: 'low' }
); );
// Combine and deduplicate by reaction ID // Combine and deduplicate by reaction ID

6
src/lib/services/auth/anonymous-signer.ts

@ -3,9 +3,7 @@
*/ */
import { generatePrivateKey } from '../security/key-management.js'; import { generatePrivateKey } from '../security/key-management.js';
import { storeAnonymousKey, getAnonymousKey } from '../cache/anonymous-key-store.js'; import { storeAnonymousKey, getAnonymousKey, getAnonymousNcryptsec } from '../cache/anonymous-key-store.js';
import { getPublicKeyFromNsec } from './nsec-signer.js';
import { signEventWithNsec } from './nsec-signer.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
/** /**
@ -16,6 +14,7 @@ export async function generateAnonymousKey(password: string): Promise<{
nsec: string; nsec: string;
}> { }> {
const nsec = generatePrivateKey(); const nsec = generatePrivateKey();
const { getPublicKeyFromNsec } = await import('./nsec-signer.js');
const pubkey = await getPublicKeyFromNsec(nsec); const pubkey = await getPublicKeyFromNsec(nsec);
// Store encrypted // Store encrypted
@ -43,7 +42,6 @@ export async function signEventWithAnonymous(
password: string password: string
): Promise<NostrEvent> { ): Promise<NostrEvent> {
// Get stored ncryptsec directly (no need to decrypt and re-encrypt) // Get stored ncryptsec directly (no need to decrypt and re-encrypt)
const { getAnonymousNcryptsec } = await import('../cache/anonymous-key-store.js');
const ncryptsec = await getAnonymousNcryptsec(pubkey); const ncryptsec = await getAnonymousNcryptsec(pubkey);
if (!ncryptsec) { if (!ncryptsec) {
throw new Error('Anonymous key not found'); throw new Error('Anonymous key not found');

43
src/lib/services/cache/event-cache.ts vendored

@ -5,6 +5,7 @@
import { getDB } from './indexeddb-store.js'; import { getDB } from './indexeddb-store.js';
import { isEventDeleted, getDeletedEventIds } from './deletion-tracker.js'; import { isEventDeleted, getDeletedEventIds } from './deletion-tracker.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
export interface CachedEvent extends NostrEvent { export interface CachedEvent extends NostrEvent {
cached_at: number; cached_at: number;
@ -270,6 +271,48 @@ export async function getRecentFeedEvents(kinds: number[], maxAge: number = 15 *
return getRecentCachedEvents(kinds, maxAge, limit); return getRecentCachedEvents(kinds, maxAge, limit);
} }
/**
* Get cached reactions for multiple event IDs (fast cache lookup)
* Returns a map of eventId -> reactions[]
*/
export async function getCachedReactionsForEvents(eventIds: string[]): Promise<Map<string, CachedEvent[]>> {
try {
if (eventIds.length === 0) return new Map();
const db = await getDB();
const tx = db.transaction('events', 'readonly');
const kindIndex = tx.store.index('kind');
// Get all cached reactions in one query
const allReactions = await kindIndex.getAll(KIND.REACTION);
await tx.done;
// Build map of eventId -> reactions[]
const reactionsMap = new Map<string, CachedEvent[]>();
for (const reaction of allReactions) {
// Find the event ID this reaction references (check both 'e' and 'E' tags)
const eventIdTag = reaction.tags.find(t => {
const tagName = t[0];
return (tagName === 'e' || tagName === 'E') && t[1] && eventIds.includes(t[1]);
});
if (eventIdTag && eventIdTag[1]) {
const eventId = eventIdTag[1];
if (!reactionsMap.has(eventId)) {
reactionsMap.set(eventId, []);
}
reactionsMap.get(eventId)!.push(reaction as CachedEvent);
}
}
return reactionsMap;
} catch (error) {
// Cache read failed (non-critical)
return new Map();
}
}
/** /**
* Clear old events (older than specified timestamp) * Clear old events (older than specified timestamp)
*/ */

5
src/lib/services/nostr/auth-handler.ts

@ -2,8 +2,6 @@
* Unified authentication handler * Unified authentication handler
*/ */
import { getNIP07Signer, signEventWithNIP07, getPublicKeyWithNIP07 } from '../auth/nip07-signer.js';
import { signEventWithNsec, getPublicKeyFromNsec } from '../auth/nsec-signer.js';
import { import {
signEventWithAnonymous, signEventWithAnonymous,
generateAnonymousKey, generateAnonymousKey,
@ -25,6 +23,7 @@ const blockedRelays: Set<string> = new Set();
* Authenticate with NIP-07 * Authenticate with NIP-07
*/ */
export async function authenticateWithNIP07(): Promise<string> { export async function authenticateWithNIP07(): Promise<string> {
const { getPublicKeyWithNIP07, signEventWithNIP07 } = await import('../auth/nip07-signer.js');
const pubkey = await getPublicKeyWithNIP07(); const pubkey = await getPublicKeyWithNIP07();
sessionManager.setSession({ sessionManager.setSession({
@ -84,6 +83,7 @@ export async function authenticateWithStoredNsec(
throw new Error('Stored nsec key not found'); throw new Error('Stored nsec key not found');
} }
// Use stored ncryptsec directly - it's already encrypted // Use stored ncryptsec directly - it's already encrypted
const { signEventWithNsec } = await import('../auth/nsec-signer.js');
return signEventWithNsec(event, ncryptsec, session.password); return signEventWithNsec(event, ncryptsec, session.password);
}, },
createdAt: Date.now() createdAt: Date.now()
@ -108,6 +108,7 @@ export async function authenticateWithNsec(
password: string password: string
): Promise<string> { ): Promise<string> {
// Derive public key from private key - NEVER log the nsec // Derive public key from private key - NEVER log the nsec
const { getPublicKeyFromNsec, signEventWithNsec } = await import('../auth/nsec-signer.js');
const pubkey = await getPublicKeyFromNsec(nsec); const pubkey = await getPublicKeyFromNsec(nsec);
// Encrypt and store the nsec key in IndexedDB // Encrypt and store the nsec key in IndexedDB

9
src/routes/login/+page.svelte

@ -1,24 +1,26 @@
<script lang="ts"> <script lang="ts">
import { authenticateWithNIP07, authenticateWithNsec, authenticateAsAnonymous, authenticateWithStoredNsec, authenticateWithStoredAnonymous } from '../../lib/services/nostr/auth-handler.js'; import { authenticateWithNIP07, authenticateWithNsec, authenticateAsAnonymous, authenticateWithStoredNsec, authenticateWithStoredAnonymous } from '../../lib/services/nostr/auth-handler.js';
import { isNIP07Available } from '../../lib/services/auth/nip07-signer.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { listNsecKeys } from '../../lib/services/cache/nsec-key-store.js'; import { listNsecKeys } from '../../lib/services/cache/nsec-key-store.js';
import { listAnonymousKeys } from '../../lib/services/cache/anonymous-key-store.js';
import { sessionManager } from '../../lib/services/auth/session-manager.js'; import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
import Icon from '../../lib/components/ui/Icon.svelte'; import Icon from '../../lib/components/ui/Icon.svelte';
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
// Check NIP-07 availability dynamically
const { isNIP07Available } = await import('../../lib/services/auth/nip07-signer.js');
nip07Available = isNIP07Available();
await loadStoredKeys(); await loadStoredKeys();
}); });
let error = $state<string | null>(null); let error = $state<string | null>(null);
let loading = $state(false); let loading = $state(false);
let activeTab = $state<'nip07' | 'nsec' | 'anonymous'>('nip07'); let activeTab = $state<'nip07' | 'nsec' | 'anonymous'>('nip07');
let nip07Available = $state(false);
// Stored keys // Stored keys
let storedNsecKeys = $state<Array<{ pubkey: string; created_at: number; keyType?: 'nsec' | 'anonymous' }>>([]); let storedNsecKeys = $state<Array<{ pubkey: string; created_at: number; keyType?: 'nsec' | 'anonymous' }>>([]);
@ -40,6 +42,7 @@
async function loadStoredKeys() { async function loadStoredKeys() {
try { try {
storedNsecKeys = await listNsecKeys(); storedNsecKeys = await listNsecKeys();
const { listAnonymousKeys } = await import('../../lib/services/cache/anonymous-key-store.js');
storedAnonymousKeys = await listAnonymousKeys(); storedAnonymousKeys = await listAnonymousKeys();
// If we have stored keys, default to showing them (not the new form) // If we have stored keys, default to showing them (not the new form)
if (storedNsecKeys.length > 0) { if (storedNsecKeys.length > 0) {
@ -62,7 +65,7 @@
} }
async function loginWithNIP07() { async function loginWithNIP07() {
if (!isNIP07Available()) { if (!nip07Available) {
error = 'NIP-07 extension not available. Please install a Nostr extension like Alby or nos2x.'; error = 'NIP-07 extension not available. Please install a Nostr extension like Alby or nos2x.';
return; return;
} }

Loading…
Cancel
Save