Browse Source

bug fixes

master
Silberengel 1 month ago
parent
commit
5d04eba942
  1. 124
      src/lib/components/content/MetadataCard.svelte
  2. 12
      src/lib/modules/comments/Comment.svelte
  3. 4
      src/lib/modules/discussions/DiscussionCard.svelte
  4. 43
      src/lib/modules/feed/FeedPage.svelte
  5. 83
      src/lib/modules/feed/FeedPost.svelte
  6. 198
      src/routes/feed/+page.svelte
  7. 43
      src/routes/topics/[name]/+page.svelte

124
src/lib/components/content/MetadataCard.svelte

@ -1,61 +1,26 @@ @@ -1,61 +1,26 @@
<script lang="ts">
import type { NostrEvent } from '../../types/nostr.js';
import EventMenu from '../EventMenu.svelte';
import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation';
import IconButton from '../ui/IconButton.svelte';
import { sessionManager } from '../../services/auth/session-manager.js';
import MediaAttachments from './MediaAttachments.svelte';
import { KIND } from '../../types/kind-lookup.js';
import { page } from '$app/stores';
interface Props {
event: NostrEvent;
showMenu?: boolean;
hideTitle?: boolean; // If true, don't show title (already displayed elsewhere)
hideImageIfInMedia?: boolean; // If true, check if image is already in MediaAttachments
hideSummary?: boolean; // If true, don't show summary (already displayed elsewhere)
}
let { event, showMenu = true, hideTitle = false, hideImageIfInMedia = true }: Props = $props();
// Normalize URL for comparison (same logic as MediaAttachments)
function normalizeUrl(url: string): string {
try {
const parsed = new URL(url);
// Remove query params and fragments for comparison
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, '');
} catch {
return url;
}
}
// Check if image URL is already in imeta tags
function isImageInImeta(imageUrl: string): boolean {
if (!imageUrl) return false;
const normalizedImageUrl = normalizeUrl(imageUrl);
for (const tag of event.tags) {
if (tag[0] === 'imeta') {
for (let i = 1; i < tag.length; i++) {
const item = tag[i];
if (item.startsWith('url ')) {
const imetaUrl = item.substring(4).trim();
if (normalizeUrl(imetaUrl) === normalizedImageUrl) {
return true;
}
}
}
}
}
return false;
}
let { event, hideTitle = false, hideSummary = false }: Props = $props();
// Extract metadata tags (using $derived for reactivity)
const rawImage = $derived(event.tags.find(t => t[0] === 'image' && t[1])?.[1]);
const image = $derived(rawImage && (!hideImageIfInMedia || !isImageInImeta(rawImage)) ? rawImage : null);
// Only show fields that are NOT in the event header (CardHeader)
// Header shows: pubkey (author), title, summary, time, client, topics, view/reply buttons, menu
// Images are shown via MediaAttachments
// So MetadataCard should only show: description, author tag (if different from pubkey), and other tags
const description = $derived(event.tags.find(t => t[0] === 'description' && t[1])?.[1]);
const summary = $derived(event.tags.find(t => t[0] === 'summary' && t[1])?.[1]);
const author = $derived(event.tags.find(t => t[0] === 'author' && t[1])?.[1]);
const rawSummary = $derived(event.tags.find(t => t[0] === 'summary' && t[1])?.[1]);
const summary = $derived(hideSummary ? null : rawSummary); // Hide if already in header
const authorTag = $derived(event.tags.find(t => t[0] === 'author' && t[1])?.[1]);
// Only show author tag if it's different from the event pubkey (which is already shown in header)
const author = $derived(authorTag && authorTag !== event.pubkey ? authorTag : null);
const title = $derived(
hideTitle ? null :
(event.tags.find(t => t[0] === 'title' && t[1])?.[1] ||
@ -71,55 +36,19 @@ @@ -71,55 +36,19 @@
})())
);
const hasMetadata = $derived(image || description || summary || author || title);
const hasMetadata = $derived(description || summary || author || title);
const hasContent = $derived(event.content && event.content.trim().length > 0);
const shouldShowMetadata = $derived(hasMetadata || !hasContent); // Show metadata if it exists OR if there's no content
// Media kinds that should auto-render media (except on /feed)
// Media kinds check (for filtering tags display)
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(event.kind));
const isOnFeedPage = $derived($page.url.pathname === '/feed');
const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage);
</script>
{#if shouldShowMetadata}
<div class="metadata-card">
<div class="metadata-header">
{#if title}
<h2 class="metadata-title">{title}</h2>
{/if}
{#if showMenu}
<div class="flex items-center gap-2">
<IconButton
icon="eye"
label="View"
size={16}
onclick={() => goto(getEventLink(event))}
/>
{#if sessionManager.isLoggedIn()}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => {}}
/>
{/if}
<EventMenu event={event} showContentActions={false} />
</div>
{/if}
</div>
{#if image}
<div class="metadata-image">
<img
src={image}
alt={title || description || summary || 'Metadata image'}
/>
</div>
{/if}
{#if shouldAutoRenderMedia}
<MediaAttachments event={event} forceRender={isMediaKind} />
{#if title}
<h2 class="metadata-title">{title}</h2>
{/if}
<div class="metadata-content">
@ -170,38 +99,17 @@ @@ -170,38 +99,17 @@
background: var(--fog-dark-post, #1f2937);
}
.metadata-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.metadata-title {
margin: 0;
margin: 0 0 1rem 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
flex: 1;
}
:global(.dark) .metadata-title {
color: var(--fog-dark-text, #f9fafb);
}
.metadata-image {
margin-bottom: 1rem;
border-radius: 0.5rem;
overflow: hidden;
}
.metadata-image img {
max-width: 600px;
width: 100%;
height: auto;
display: block;
}
.metadata-content {
display: flex;
flex-direction: column;

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

@ -61,6 +61,18 @@ @@ -61,6 +61,18 @@
return clientTag?.[1] || null;
}
function getTitle(): string | null {
const titleTag = comment.tags.find((t) => t[0] === 'title');
const title = titleTag?.[1];
return title && title.trim() ? title.trim() : null;
}
function getSummary(): string | null {
const summaryTag = comment.tags.find((t) => t[0] === 'summary');
const summary = summaryTag?.[1];
return summary && summary.trim() ? summary.trim() : null;
}
function handleReply() {
if (threadId) {
// Show reply form directly below this comment

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

@ -216,7 +216,7 @@ @@ -216,7 +216,7 @@
{/if}
<!-- Display metadata (title, author, summary, description, image) -->
<MetadataCard event={thread} hideTitle={true} hideImageIfInMedia={true} />
<MetadataCard event={thread} hideTitle={true} hideSummary={true} />
<div class="post-content mb-2">
{#if shouldAutoRenderMedia}
@ -268,7 +268,7 @@ @@ -268,7 +268,7 @@
{/if}
<!-- Display metadata (title, author, summary, description, image) -->
<MetadataCard event={thread} hideTitle={true} hideImageIfInMedia={true} />
<MetadataCard event={thread} hideTitle={true} hideSummary={true} />
<div class="post-content mb-2">
<MediaAttachments event={thread} forceRender={isMediaKind} />

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

@ -12,10 +12,9 @@ @@ -12,10 +12,9 @@
interface Props {
singleRelay?: string;
filterResult?: { type: 'event' | 'pubkey' | 'text' | null; value: string | null };
}
let { singleRelay, filterResult = { type: null, value: null } }: Props = $props();
let { singleRelay }: Props = $props();
// Expose API for parent component via component reference
// Note: The warning about loadOlderEvents is a false positive - functions don't need to be reactive
@ -72,44 +71,8 @@ @@ -72,44 +71,8 @@
}
}
// 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);
// Use all events directly (no filtering)
let events = $derived(allEvents);
// Get preloaded referenced event for a post (from e, a, or q tag)
function getReferencedEventForPost(event: NostrEvent): NostrEvent | null {

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

@ -549,9 +549,8 @@ @@ -549,9 +549,8 @@
});
}
function getReplyEventId(): string | null {
// Priority order: e/E (reply), q (quote), a/A (parameterized), i/I (identifier)
// Get reply-to event ID from e/E tags only (excluding q tags)
function getReplyToEventId(): string | null {
// 1. Check for e/E tags (NIP-10 replies)
const replyTag = post.tags.find((t) => (t[0] === 'e' || t[0] === 'E') && t[3] === 'reply');
if (replyTag && replyTag[1]) return replyTag[1];
@ -560,13 +559,29 @@ @@ -560,13 +559,29 @@
const eTag = post.tags.find((t) => (t[0] === 'e' || t[0] === 'E') && t[1] && t[1] !== rootId && t[1] !== post.id);
if (eTag && eTag[1]) return eTag[1];
return null;
}
// Get reply-to a-tag value from a/A tags
function getReplyToATag(): string | null {
const aTag = post.tags.find((t) => (t[0] === 'a' || t[0] === 'A') && t[1]);
return aTag?.[1] || null;
}
function getReplyEventId(): string | null {
// Priority order: e/E (reply), q (quote), a/A (parameterized), i/I (identifier)
// 1. Check for e/E tags (NIP-10 replies)
const replyToEventId = getReplyToEventId();
if (replyToEventId) return replyToEventId;
// 2. Check for q tag (quoted event)
const qTag = post.tags.find((t) => t[0] === 'q' && t[1]);
if (qTag && qTag[1]) return qTag[1];
// 3. Check for a/A tags (parameterized replaceable events)
const aTag = post.tags.find((t) => (t[0] === 'a' || t[0] === 'A') && t[1]);
if (aTag && aTag[1]) return aTag[1];
const aTagValue = getReplyToATag();
if (aTagValue) return aTagValue;
// 4. Check for i/I tags (identifier references)
const iTag = post.tags.find((t) => (t[0] === 'i' || t[0] === 'I') && t[1]);
@ -595,6 +610,58 @@ @@ -595,6 +610,58 @@
return null;
}
// Check if a-tag references the same event as the quoted event
function doesATagMatchQuotedEvent(aTagValue: string, quotedEvent: NostrEvent | null | undefined): boolean {
if (!aTagValue || !quotedEvent) return false;
// Parse a-tag: format is "kind:pubkey:d-tag"
const parts = aTagValue.split(':');
if (parts.length !== 3) return false;
const [kindStr, pubkey, dTag] = parts;
const kind = parseInt(kindStr, 10);
// Check if quoted event matches a-tag reference
if (quotedEvent.kind !== kind || quotedEvent.pubkey !== pubkey) {
return false;
}
// Check d-tag
const quotedDTag = quotedEvent.tags.find((t) => t[0] === 'd' && t[1]);
if (!quotedDTag || quotedDTag[1] !== dTag) {
return false;
}
return true;
}
// Check if reply-to and quote reference the same event
function shouldShowReply(): boolean {
const replyToEventId = getReplyToEventId();
const quotedEventId = getQuotedEventId();
// If e/E tag and q tag reference the same event ID, only show quote
if (replyToEventId && quotedEventId && replyToEventId === quotedEventId) {
return false;
}
// Check a-tags: if a-tag and q-tag reference the same event
const replyToATag = getReplyToATag();
if (replyToATag && quotedEventId) {
// If we have the quoted event loaded, check if it matches the a-tag reference
if (providedQuotedEvent && doesATagMatchQuotedEvent(replyToATag, providedQuotedEvent)) {
return false; // Same event referenced, only show quote
}
// Also check if the quoted event ID matches the a-tag value directly (unlikely but possible)
if (quotedEventId === replyToATag) {
return false;
}
}
return isReply();
}
function getRootEventId(): string | null {
const rootTag = post.tags.find((t) => t[0] === 'root');
@ -821,7 +888,7 @@ @@ -821,7 +888,7 @@
>
{#if fullView}
<!-- Full view: show complete content with markdown, media, profile pics, reactions -->
{#if isReply()}
{#if shouldShowReply()}
<ReplyContext
parentEvent={providedParentEvent || undefined}
parentEventId={getReplyEventId() || undefined}
@ -838,7 +905,7 @@ @@ -838,7 +905,7 @@
/>
{/if}
<MetadataCard event={post} hideTitle={true} hideImageIfInMedia={true} />
<MetadataCard event={post} hideTitle={true} hideSummary={true} />
<CardHeader
pubkey={post.pubkey}
@ -942,7 +1009,7 @@ @@ -942,7 +1009,7 @@
<!-- Show metadata in feed view when content is empty, but skip for media kinds (MediaAttachments handles those) -->
{#if !fullView && (!post.content || !post.content.trim()) && !isMediaKind}
<MetadataCard event={post} hideTitle={true} hideImageIfInMedia={true} />
<MetadataCard event={post} hideTitle={true} hideSummary={true} />
{/if}
<div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}>

198
src/routes/feed/+page.svelte

@ -1,18 +1,10 @@ @@ -1,18 +1,10 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import FeedPage from '../../lib/modules/feed/FeedPage.svelte';
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 { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { KIND } from '../../lib/types/kind-lookup.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte';
import Icon from '../../lib/components/ui/Icon.svelte';
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
let feedPageComponent: {
loadOlderEvents: () => Promise<void>;
loadingMore: boolean;
@ -21,20 +13,6 @@ @@ -21,20 +13,6 @@
loadWaitingRoomEvents: () => void;
} | null = $state(null);
function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) {
filterResult = result;
}
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
searchResults = results;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
onMount(async () => {
await nostrClient.initialize();
});
@ -52,19 +30,8 @@ @@ -52,19 +30,8 @@
</a>
</div>
<div class="feed-controls">
<div class="search-section">
<UnifiedSearch
mode="search"
bind:this={unifiedSearchComponent}
allowedKinds={[KIND.SHORT_TEXT_NOTE]}
hideDropdownResults={true}
onSearchResults={handleSearchResults}
onFilterChange={handleFilterChange}
placeholder="Search kind 1 events by pubkey, p, q tags, or content..."
/>
</div>
<div class="feed-header-buttons">
{#if feedPageComponent && feedPageComponent.waitingRoomEvents.length > 0 && !searchResults.events.length && !searchResults.profiles.length}
{#if feedPageComponent && feedPageComponent.waitingRoomEvents.length > 0}
<button
onclick={() => feedPageComponent?.loadWaitingRoomEvents()}
class="see-new-events-btn-header"
@ -72,7 +39,7 @@ @@ -72,7 +39,7 @@
See {feedPageComponent.waitingRoomEvents.length} new event{feedPageComponent.waitingRoomEvents.length === 1 ? '' : 's'}
</button>
{/if}
{#if feedPageComponent && feedPageComponent.hasMoreEvents && !searchResults.events.length && !searchResults.profiles.length}
{#if feedPageComponent && feedPageComponent.hasMoreEvents}
<button
onclick={() => feedPageComponent?.loadOlderEvents()}
disabled={feedPageComponent.loadingMore || !feedPageComponent.hasMoreEvents}
@ -85,39 +52,7 @@ @@ -85,39 +52,7 @@
</div>
</div>
{#if searchResults.events.length > 0 || searchResults.profiles.length > 0}
<div class="search-results-section">
<h2 class="results-title">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 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}
</div>
{:else}
<FeedPage filterResult={filterResult} bind:this={feedPageComponent} />
{/if}
<FeedPage bind:this={feedPageComponent} />
</div>
</main>
@ -161,33 +96,14 @@ @@ -161,33 +96,14 @@
.feed-controls {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
gap: 1rem;
}
@media (max-width: 640px) {
.feed-controls {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
}
.search-section {
flex: 1;
min-width: 0;
}
@media (max-width: 640px) {
.search-section {
width: 100%;
flex: none;
}
.search-section :global(.unified-search-container) {
max-width: 100%;
width: 100%;
justify-content: flex-start;
}
}
@ -266,108 +182,4 @@ @@ -266,108 +182,4 @@
border-color: var(--fog-dark-accent, #64748b);
}
.search-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) .search-results-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.results-title {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #475569);
}
:global(.dark) .results-title {
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, #52667a);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #a8b8d0);
}
.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);
}
</style>

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

@ -182,9 +182,7 @@ @@ -182,9 +182,7 @@
{:else}
<div class="events-list">
{#each paginatedEvents as event (event.id)}
<div class="event-item">
<FeedPost post={event} />
</div>
<FeedPost post={event} />
{/each}
</div>
@ -292,45 +290,6 @@ @@ -292,45 +290,6 @@
max-width: 100%;
}
.event-item {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
overflow: hidden;
width: 100%;
max-width: 100%;
box-sizing: border-box;
word-break: break-word;
overflow-wrap: anywhere;
}
.event-item :global(.post-header-left) > :global(span) {
white-space: normal !important;
word-break: break-word;
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.event-item {
padding: 0.5rem;
margin: 0;
width: 100%;
max-width: 100%;
}
.event-item :global(.post-header-left) > :global(span) {
white-space: normal !important;
word-break: break-word;
overflow-wrap: anywhere;
}
}
:global(.dark) .event-item {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.pagination-controls {
padding: 1rem;
}

Loading…
Cancel
Save