Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
566fea2700
  1. 56
      src/lib/components/content/MarkdownRenderer.svelte
  2. 198
      src/lib/components/content/MediaAttachments.svelte
  3. 8
      src/lib/components/layout/ProfileBadge.svelte
  4. 264
      src/lib/modules/comments/CommentThread.svelte
  5. 2
      src/lib/modules/feed/FeedPost.svelte
  6. 203
      src/lib/modules/profiles/ProfilePage.svelte
  7. 23
      src/lib/modules/threads/ThreadView.svelte
  8. 64
      src/routes/thread/[id]/+page.svelte

56
src/lib/components/content/MarkdownRenderer.svelte

@ -14,6 +14,56 @@ @@ -14,6 +14,56 @@
let rendered = $state('');
let containerElement: HTMLDivElement | null = $state(null);
// Process rendered HTML to add lazy loading and prevent autoplay
function processMediaElements(html: string): string {
// Add loading="lazy" to all images
html = html.replace(/<img([^>]*)>/gi, (match, attrs) => {
// Don't add if already has loading attribute
if (/loading\s*=/i.test(attrs)) {
return match;
}
return `<img${attrs} loading="lazy">`;
});
// Ensure videos don't autoplay and use preload="none"
html = html.replace(/<video([^>]*)>/gi, (match, attrs) => {
let newAttrs = attrs;
// Remove autoplay if present
newAttrs = newAttrs.replace(/\s*autoplay\s*/gi, ' ');
// Set preload to none if not already set
if (!/preload\s*=/i.test(newAttrs)) {
newAttrs += ' preload="none"';
} else {
newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"');
}
// Ensure autoplay is explicitly false
if (!/autoplay\s*=/i.test(newAttrs)) {
newAttrs += ' autoplay="false"';
}
return `<video${newAttrs}>`;
});
// Ensure audio doesn't autoplay and use preload="none"
html = html.replace(/<audio([^>]*)>/gi, (match, attrs) => {
let newAttrs = attrs;
// Remove autoplay if present
newAttrs = newAttrs.replace(/\s*autoplay\s*/gi, ' ');
// Set preload to none if not already set
if (!/preload\s*=/i.test(newAttrs)) {
newAttrs += ' preload="none"';
} else {
newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"');
}
// Ensure autoplay is explicitly false
if (!/autoplay\s*=/i.test(newAttrs)) {
newAttrs += ' autoplay="false"';
}
return `<audio${newAttrs}>`;
});
return html;
}
$effect(() => {
if (content) {
// Process NIP-21 links before markdown parsing
@ -42,6 +92,9 @@ @@ -42,6 +92,9 @@
parseResult.then((html) => {
let finalHtml = sanitizeMarkdown(html);
// Process media elements for lazy loading
finalHtml = processMediaElements(finalHtml);
// Replace placeholders with actual NIP-21 links
for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';
@ -82,6 +135,9 @@ @@ -82,6 +135,9 @@
} else {
let finalHtml = sanitizeMarkdown(parseResult);
// Process media elements for lazy loading
finalHtml = processMediaElements(finalHtml);
// Replace placeholders with actual NIP-21 links
for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';

198
src/lib/components/content/MediaAttachments.svelte

@ -1,11 +1,16 @@ @@ -1,11 +1,16 @@
<script lang="ts">
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
interface Props {
event: NostrEvent;
}
let { event }: Props = $props();
// Track which media items should be loaded
let loadedMedia = $state<Set<string>>(new Set());
let mediaRefs = $state<Map<string, HTMLElement>>(new Map());
interface MediaItem {
url: string;
@ -118,7 +123,7 @@ @@ -118,7 +123,7 @@
}
}
// 4. Extract from markdown content (images)
// 4. Extract from markdown content (images in markdown syntax)
const imageRegex = /!\[.*?\]\((.*?)\)/g;
let match;
while ((match = imageRegex.exec(event.content)) !== null) {
@ -134,22 +139,114 @@ @@ -134,22 +139,114 @@
}
}
// 5. Extract plain image URLs from content (URLs ending in image extensions)
// Match URLs that end with common image extensions, handling various formats
// This regex matches URLs that:
// - Start with http:// or https://
// - Contain valid URL characters
// - End with image file extensions
// - May have query parameters
const imageUrlRegex = /https?:\/\/[^\s<>"'\n\r]+\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?[^\s<>"'\n\r]*)?/gi;
let urlMatch;
const processedContent = event.content;
while ((urlMatch = imageUrlRegex.exec(processedContent)) !== null) {
let url = urlMatch[0];
// Clean up URL - remove trailing punctuation that might have been captured
url = url.replace(/[.,;:!?]+$/, '');
// Remove closing parentheses if URL was in parentheses
if (url.endsWith(')') && !url.includes('(')) {
url = url.slice(0, -1);
}
const normalized = normalizeUrl(url);
if (!seen.has(normalized) && url.length > 10) { // Basic validation
media.push({
url,
type: 'image',
source: 'content'
});
seen.add(normalized);
}
}
return media;
}
const mediaItems = $derived(extractMedia());
const coverImage = $derived(mediaItems.find((m) => m.source === 'image-tag'));
const otherMedia = $derived(mediaItems.filter((m) => m.source !== 'image-tag'));
// Intersection Observer for lazy loading
let observer: IntersectionObserver | null = $state(null);
onMount(() => {
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const url = entry.target.getAttribute('data-media-url');
if (url) {
loadedMedia.add(url);
observer?.unobserve(entry.target);
}
}
});
},
{
rootMargin: '50px' // Start loading 50px before element is visible
}
);
return () => {
observer?.disconnect();
observer = null;
};
});
// Action to set media ref and observe it
function mediaRefAction(node: HTMLElement, url: string) {
mediaRefs.set(url, node);
// Observe the element when it's added
if (observer) {
observer.observe(node);
}
return {
destroy() {
if (observer) {
observer.unobserve(node);
}
mediaRefs.delete(url);
}
};
}
function shouldLoad(url: string): boolean {
// Always load cover images immediately
if (coverImage && coverImage.url === url) {
return true;
}
return loadedMedia.has(url);
}
</script>
{#if coverImage}
<div class="cover-image mb-4">
<img
src={coverImage.url}
alt=""
class="w-full max-h-96 object-cover rounded"
loading="lazy"
/>
{#if shouldLoad(coverImage.url)}
<img
src={coverImage.url}
alt=""
class="w-full max-h-96 object-cover rounded"
loading="lazy"
/>
{:else}
<div
class="media-placeholder w-full max-h-96 bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center"
use:mediaRefAction={coverImage.url}
data-media-url={coverImage.url}
style="min-height: 200px;"
>
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading image...</span>
</div>
{/if}
</div>
{/if}
@ -158,31 +255,72 @@ @@ -158,31 +255,72 @@
{#each otherMedia as item}
{#if item.type === 'image'}
<div class="media-item">
<img
src={item.url}
alt=""
class="max-w-full rounded"
loading="lazy"
/>
{#if shouldLoad(item.url)}
<img
src={item.url}
alt=""
class="max-w-full rounded"
loading="lazy"
/>
{:else}
<div
class="media-placeholder bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center"
use:mediaRefAction={item.url}
data-media-url={item.url}
style="min-height: 150px; min-width: 150px;"
>
<span class="text-fog-text-light dark:text-fog-dark-text-light text-sm">Loading...</span>
</div>
{/if}
</div>
{:else if item.type === 'video'}
<div class="media-item">
<video
src={item.url}
controls
preload="metadata"
class="max-w-full rounded"
style="max-height: 500px;"
>
<track kind="captions" />
Your browser does not support the video tag.
</video>
{#if shouldLoad(item.url)}
<video
src={item.url}
controls
preload="none"
class="max-w-full rounded"
style="max-height: 500px;"
autoplay={false}
muted={false}
>
<track kind="captions" />
Your browser does not support the video tag.
</video>
{:else}
<div
class="media-placeholder bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center"
use:mediaRefAction={item.url}
data-media-url={item.url}
style="min-height: 200px; min-width: 200px;"
>
<span class="text-fog-text-light dark:text-fog-dark-text-light"> Video</span>
</div>
{/if}
</div>
{:else if item.type === 'audio'}
<div class="media-item">
<audio src={item.url} controls preload="metadata" class="w-full">
Your browser does not support the audio tag.
</audio>
{#if shouldLoad(item.url)}
<audio
src={item.url}
controls
preload="none"
class="w-full"
autoplay={false}
>
Your browser does not support the audio tag.
</audio>
{:else}
<div
class="media-placeholder bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center"
use:mediaRefAction={item.url}
data-media-url={item.url}
style="min-height: 60px; width: 100%;"
>
<span class="text-fog-text-light dark:text-fog-dark-text-light">🎵 Audio</span>
</div>
{/if}
</div>
{:else if item.type === 'file'}
<div class="media-item file-item">
@ -254,4 +392,12 @@ @@ -254,4 +392,12 @@
.file-link:hover {
text-decoration: underline;
}
.media-placeholder {
border: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .media-placeholder {
border-color: var(--fog-dark-border, #374151);
}
</style>

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

@ -123,14 +123,6 @@ @@ -123,14 +123,6 @@
</div>
{/if}
<span class="truncate min-w-0">{profile?.name || shortenedNpub}</span>
{#if activityStatus && activityMessage}
<span
class="activity-dot w-2 h-2 rounded-full flex-shrink-0"
style="background-color: {getActivityColor()}"
title={activityMessage}
aria-label={activityMessage}
></span>
{/if}
{#if status}
<span class="status-text text-sm text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap">({status})</span>
{/if}

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

@ -2,21 +2,27 @@ @@ -2,21 +2,27 @@
import Comment from './Comment.svelte';
import CommentForm from './CommentForm.svelte';
import ZapReceiptReply from '../feed/ZapReceiptReply.svelte';
import FeedPost from '../feed/FeedPost.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
threadId: string; // The kind 11 thread event ID
threadId: string; // The event ID
event?: NostrEvent; // The event itself (optional, used to determine reply types)
}
let { threadId }: Props = $props();
let { threadId, event }: Props = $props();
let comments = $state<NostrEvent[]>([]);
let kind1Replies = $state<NostrEvent[]>([]);
let yakBacks = $state<NostrEvent[]>([]);
let zapReceipts = $state<NostrEvent[]>([]);
let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null);
const isKind1 = $derived(event?.kind === 1);
onMount(async () => {
await nostrClient.initialize();
@ -28,29 +34,47 @@ @@ -28,29 +34,47 @@
try {
const config = nostrClient.getConfig();
const relays = relayManager.getCommentReadRelays();
const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...relays, ...feedRelays])];
// First, fetch comments (kind 1111) that directly reference this thread
// NIP-22: Comments use K tag for kind and E tag for event
const directCommentFilters = [
{
kinds: [1111],
'#K': ['11'], // Comments on kind 11 threads
'#E': [threadId] // Comments on this specific thread
}
const replyFilters: any[] = [
{ kinds: [9735], '#e': [threadId] }, // Zap receipts
{ kinds: [1244], '#e': [threadId] }, // Yak backs (voice replies)
];
let directComments = await nostrClient.fetchEvents(
directCommentFilters,
relays,
// For kind 1 events, also fetch kind 1 replies
if (isKind1) {
replyFilters.push({ kinds: [1], '#e': [threadId] });
}
// For all events, fetch kind 1111 comments
// For kind 11 threads, use #E and #K tags (NIP-22)
// For other events, use #e tag
if (event?.kind === 11) {
replyFilters.push(
{ kinds: [1111], '#E': [threadId], '#K': ['11'] }, // NIP-22 standard (uppercase)
{ kinds: [1111], '#e': [threadId] } // Fallback (lowercase)
);
} else {
replyFilters.push({ kinds: [1111], '#e': [threadId] });
}
const allReplies = await nostrClient.fetchEvents(
replyFilters,
allRelays,
{ useCache: true, cacheResults: true }
);
comments = directComments;
// Separate by type
comments = allReplies.filter(e => e.kind === 1111);
kind1Replies = allReplies.filter(e => e.kind === 1);
yakBacks = allReplies.filter(e => e.kind === 1244);
zapReceipts = allReplies.filter(e => e.kind === 9735);
// Recursively fetch all nested replies
await fetchNestedReplies();
// Fetch zap receipts that reference this thread or any comment
// Fetch zap receipts that reference this thread or any comment/reply
await fetchZapReceipts();
} catch (error) {
console.error('Error loading comments:', error);
@ -61,69 +85,63 @@ @@ -61,69 +85,63 @@
async function fetchNestedReplies() {
const relays = relayManager.getCommentReadRelays();
let hasNewComments = true;
const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...relays, ...feedRelays])];
let hasNewReplies = true;
let iterations = 0;
const maxIterations = 10; // Prevent infinite loops
// Keep fetching until we have all nested replies
while (hasNewComments && iterations < maxIterations) {
while (hasNewReplies && iterations < maxIterations) {
iterations++;
hasNewComments = false;
const allCommentIds = new Set(comments.map(c => c.id));
hasNewReplies = false;
const allReplyIds = new Set([
...comments.map(c => c.id),
...kind1Replies.map(r => r.id),
...yakBacks.map(y => y.id)
]);
if (allCommentIds.size > 0) {
// Fetch comments that reference any comment we have (replies to replies)
const replyToCommentsFilters = [
{
kinds: [1111],
'#K': ['11'], // Comments on kind 11 threads
'#E': Array.from(allCommentIds) // Comments that reference any of our comments
if (allReplyIds.size > 0) {
const nestedFilters: any[] = [
{ kinds: [9735], '#e': Array.from(allReplyIds) }, // Zap receipts
{ kinds: [1244], '#e': Array.from(allReplyIds) }, // Yak backs
];
// For kind 1 events, also fetch nested kind 1 replies
if (isKind1) {
nestedFilters.push({ kinds: [1], '#e': Array.from(allReplyIds) });
}
];
const replyToComments = await nostrClient.fetchEvents(
replyToCommentsFilters,
relays,
{ useCache: true, cacheResults: true }
);
// Add new comments that are replies to our comments
for (const reply of replyToComments) {
if (!allCommentIds.has(reply.id)) {
comments.push(reply);
hasNewComments = true;
// Fetch nested comments
if (event?.kind === 11) {
nestedFilters.push(
{ kinds: [1111], '#E': Array.from(allReplyIds), '#K': ['11'] },
{ kinds: [1111], '#e': Array.from(allReplyIds) }
);
} else {
nestedFilters.push({ kinds: [1111], '#e': Array.from(allReplyIds) });
}
}
}
// Also fetch missing parent comments that are referenced but not loaded
const missingReplyIds = new Set<string>();
for (const comment of comments) {
const eTag = comment.tags.find((t) => t[0] === 'E') || comment.tags.find((t) => t[0] === 'e' && t[1] !== comment.id);
if (eTag && eTag[1] && eTag[1] !== threadId) {
const parentExists = comments.some(c => c.id === eTag[1]);
if (!parentExists) {
missingReplyIds.add(eTag[1]);
const nestedReplies = await nostrClient.fetchEvents(
nestedFilters,
allRelays,
{ useCache: true, cacheResults: true }
);
// Add new replies by type
for (const reply of nestedReplies) {
if (reply.kind === 1111 && !comments.some(c => c.id === reply.id)) {
comments.push(reply);
hasNewReplies = true;
} else if (reply.kind === 1 && !kind1Replies.some(r => r.id === reply.id)) {
kind1Replies.push(reply);
hasNewReplies = true;
} else if (reply.kind === 1244 && !yakBacks.some(y => y.id === reply.id)) {
yakBacks.push(reply);
hasNewReplies = true;
}
}
}
if (missingReplyIds.size > 0) {
const replyComments = await nostrClient.fetchEvents(
[{ kinds: [1111], ids: Array.from(missingReplyIds) }],
relays,
{ useCache: true, cacheResults: true }
);
// Add new parent comments
for (const reply of replyComments) {
const exists = comments.some(c => c.id === reply.id);
if (!exists) {
comments.push(reply);
hasNewComments = true;
}
}
}
}
}
@ -131,24 +149,32 @@ @@ -131,24 +149,32 @@
async function fetchZapReceipts() {
const config = nostrClient.getConfig();
const relays = relayManager.getCommentReadRelays();
const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...relays, ...feedRelays])];
// Keep fetching until we have all zaps
let previousCount = -1;
while (zapReceipts.length !== previousCount) {
previousCount = zapReceipts.length;
const allEventIds = new Set([threadId, ...comments.map(c => c.id), ...zapReceipts.map(z => z.id)]);
const allEventIds = new Set([
threadId,
...comments.map(c => c.id),
...kind1Replies.map(r => r.id),
...yakBacks.map(y => y.id),
...zapReceipts.map(z => z.id)
]);
// Fetch zap receipts that reference thread or any comment/zap
// Fetch zap receipts that reference thread or any comment/reply/yak/zap
const zapFilters = [
{
kinds: [9735],
'#e': Array.from(allEventIds) // Zap receipts for thread and all comments/zaps
'#e': Array.from(allEventIds) // Zap receipts for thread and all replies
}
];
const zapEvents = await nostrClient.fetchEvents(
zapFilters,
relays,
allRelays,
{ useCache: true, cacheResults: true }
);
@ -170,43 +196,54 @@ @@ -170,43 +196,54 @@
}
}
// Check if any zaps reference comments we don't have
const missingCommentIds = new Set<string>();
// Check if any zaps reference events we don't have
const missingEventIds = new Set<string>();
for (const zap of validZaps) {
const eTag = zap.tags.find((t) => t[0] === 'e');
if (eTag && eTag[1] && eTag[1] !== threadId) {
if (!comments.some(c => c.id === eTag[1])) {
missingCommentIds.add(eTag[1]);
const exists = comments.some(c => c.id === eTag[1])
|| kind1Replies.some(r => r.id === eTag[1])
|| yakBacks.some(y => y.id === eTag[1]);
if (!exists) {
missingEventIds.add(eTag[1]);
}
}
}
// Fetch missing comments
if (missingCommentIds.size > 0) {
const missingComments = await nostrClient.fetchEvents(
[{ kinds: [1111], ids: Array.from(missingCommentIds) }],
relays,
// Fetch missing events (could be comments, replies, or yak backs)
if (missingEventIds.size > 0) {
const missingEvents = await nostrClient.fetchEvents(
[
{ kinds: [1111], ids: Array.from(missingEventIds) },
{ kinds: [1], ids: Array.from(missingEventIds) },
{ kinds: [1244], ids: Array.from(missingEventIds) }
],
allRelays,
{ useCache: true, cacheResults: true }
);
for (const comment of missingComments) {
if (!comments.some(c => c.id === comment.id)) {
comments.push(comment);
for (const event of missingEvents) {
if (event.kind === 1111 && !comments.some(c => c.id === event.id)) {
comments.push(event);
} else if (event.kind === 1 && !kind1Replies.some(r => r.id === event.id)) {
kind1Replies.push(event);
} else if (event.kind === 1244 && !yakBacks.some(y => y.id === event.id)) {
yakBacks.push(event);
}
}
// Fetch nested replies to newly found comments
// Fetch nested replies to newly found events
await fetchNestedReplies();
}
}
}
function sortThreadItems(items: Array<{ event: NostrEvent; type: 'comment' | 'zap' }>): Array<{ event: NostrEvent; type: 'comment' | 'zap' }> {
function sortThreadItems(items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> {
// Build thread structure similar to feed
const eventMap = new Map<string, { event: NostrEvent; type: 'comment' | 'zap' }>();
const eventMap = new Map<string, { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>();
const replyMap = new Map<string, string[]>(); // parentId -> childIds[]
const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'zap' }> = [];
const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [];
const allEventIds = new Set<string>();
// First pass: build event map and collect all event IDs
@ -217,19 +254,19 @@ @@ -217,19 +254,19 @@
// Second pass: determine parent-child relationships
for (const item of items) {
// Check if this is a reply
// Check if this is a reply - check both uppercase E (NIP-22) and lowercase e tags
const eTag = item.event.tags.find((t) => t[0] === 'E') || item.event.tags.find((t) => t[0] === 'e' && t[1] !== item.event.id);
const parentId = eTag?.[1];
if (parentId) {
// Check if parent is the thread or another comment/zap we have
// Check if parent is the thread or another reply we have
if (parentId === threadId || allEventIds.has(parentId)) {
// This is a reply
if (!replyMap.has(parentId)) {
replyMap.set(parentId, []);
}
replyMap.get(parentId)!.push(item.event.id);
} else {
// This is a reply
if (!replyMap.has(parentId)) {
replyMap.set(parentId, []);
}
replyMap.get(parentId)!.push(item.event.id);
} else {
// Parent not found - treat as root item (might be a missing parent)
rootItems.push(item);
}
@ -240,10 +277,10 @@ @@ -240,10 +277,10 @@
}
// Third pass: recursively collect all items in thread order
const result: Array<{ event: NostrEvent; type: 'comment' | 'zap' }> = [];
const result: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [];
const processed = new Set<string>();
function addThread(item: { event: NostrEvent; type: 'comment' | 'zap' }) {
function addThread(item: { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }) {
if (processed.has(item.event.id)) return;
processed.add(item.event.id);
@ -253,7 +290,7 @@ @@ -253,7 +290,7 @@
const replies = replyMap.get(item.event.id) || [];
const replyItems = replies
.map(id => eventMap.get(id))
.filter((item): item is { event: NostrEvent; type: 'comment' | 'zap' } => item !== undefined)
.filter((item): item is { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' } => item !== undefined)
.sort((a, b) => a.event.created_at - b.event.created_at); // Sort replies chronologically
for (const reply of replyItems) {
@ -270,9 +307,11 @@ @@ -270,9 +307,11 @@
return result;
}
function getThreadItems(): Array<{ event: NostrEvent; type: 'comment' | 'zap' }> {
const items: Array<{ event: NostrEvent; type: 'comment' | 'zap' }> = [
function getThreadItems(): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> {
const items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [
...comments.map(c => ({ event: c, type: 'comment' as const })),
...kind1Replies.map(r => ({ event: r, type: 'reply' as const })),
...yakBacks.map(y => ({ event: y, type: 'yak' as const })),
...zapReceipts.map(z => ({ event: z, type: 'zap' as const }))
];
return sortThreadItems(items);
@ -282,8 +321,11 @@ @@ -282,8 +321,11 @@
// NIP-22: E tag (uppercase) points to parent event, or lowercase e tag
const eTag = event.tags.find((t) => t[0] === 'E') || event.tags.find((t) => t[0] === 'e' && t[1] !== event.id);
if (eTag && eTag[1]) {
// Find parent in comments or zap receipts
const parent = comments.find((c) => c.id === eTag[1]) || zapReceipts.find((z) => z.id === eTag[1]);
// Find parent in comments, replies, yak backs, or zap receipts
const parent = comments.find((c) => c.id === eTag[1])
|| kind1Replies.find((r) => r.id === eTag[1])
|| yakBacks.find((y) => y.id === eTag[1])
|| zapReceipts.find((z) => z.id === eTag[1]);
if (parent) return parent;
// If parent not found, it might be the thread itself
@ -307,8 +349,8 @@ @@ -307,8 +349,8 @@
{#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading comments...</p>
{:else if comments.length === 0 && zapReceipts.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No comments yet. Be the first to comment!</p>
{:else if comments.length === 0 && kind1Replies.length === 0 && yakBacks.length === 0 && zapReceipts.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No replies yet. Be the first to reply!</p>
{:else}
<div class="comments-list">
{#each getThreadItems() as item (item.event.id)}
@ -319,6 +361,16 @@ @@ -319,6 +361,16 @@
parentEvent={parent}
onReply={handleReply}
/>
{:else if item.type === 'reply'}
<!-- Kind 1 reply - render as FeedPost -->
<div class="kind1-reply mb-4">
<FeedPost post={item.event} />
</div>
{:else if item.type === 'yak'}
<!-- Yak back (kind 1244) - render as FeedPost -->
<div class="yak-back mb-4">
<FeedPost post={item.event} />
</div>
{:else if item.type === 'zap'}
<ZapReceiptReply
zapReceipt={item.event}

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

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import QuotedContext from '../../components/content/QuotedContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
@ -222,6 +223,7 @@ @@ -222,6 +223,7 @@
</div>
<div class="post-content mb-2">
<MediaAttachments event={post} />
<MarkdownRenderer content={post.content} />
</div>

203
src/lib/modules/profiles/ProfilePage.svelte

@ -6,6 +6,7 @@ @@ -6,6 +6,7 @@
import { fetchProfile, fetchUserStatus, type ProfileData } from '../../services/user-data.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { nip19 } from 'nostr-tools';
@ -15,8 +16,80 @@ @@ -15,8 +16,80 @@
let userStatus = $state<string | null>(null);
let posts = $state<NostrEvent[]>([]);
let responses = $state<NostrEvent[]>([]);
let interactionsWithMe = $state<NostrEvent[]>([]);
let loading = $state(true);
let activeTab = $state<'posts' | 'responses'>('posts');
let activeTab = $state<'posts' | 'responses' | 'interactions'>('posts');
let nip05Validations = $state<Map<string, boolean | null>>(new Map()); // null = checking, true = valid, false = invalid
// Get current logged-in user's pubkey
let currentUserPubkey = $state<string | null>(sessionManager.getCurrentPubkey());
// Subscribe to session changes
$effect(() => {
const unsubscribe = sessionManager.session.subscribe((session) => {
currentUserPubkey = session?.pubkey || null;
// Reload interactions if session changes and we're viewing another user's profile
if (profile) {
const pubkey = decodePubkey($page.params.pubkey);
if (pubkey && currentUserPubkey && currentUserPubkey !== pubkey) {
// Reload interactions tab data
loadInteractionsWithMe(pubkey, currentUserPubkey);
} else {
interactionsWithMe = [];
}
}
});
return unsubscribe;
});
async function loadInteractionsWithMe(profilePubkey: string, currentUserPubkey: string) {
if (!currentUserPubkey || currentUserPubkey === profilePubkey) {
interactionsWithMe = [];
return;
}
try {
const interactionRelays = relayManager.getFeedResponseReadRelays();
// Fetch current user's posts to find replies
const currentUserPosts = await nostrClient.fetchEvents(
[{ kinds: [1], authors: [currentUserPubkey], limit: 50 }],
interactionRelays,
{ useCache: true, cacheResults: true }
);
const currentUserPostIds = new Set(currentUserPosts.map(p => p.id));
const interactionEvents = await nostrClient.fetchEvents(
[
{ kinds: [1], authors: [profilePubkey], '#e': Array.from(currentUserPostIds), limit: 20 }, // Replies to current user's posts
{ kinds: [1], authors: [profilePubkey], '#p': [currentUserPubkey], limit: 20 } // Mentions of current user
],
interactionRelays,
{ useCache: true, cacheResults: true }
);
// Deduplicate and filter to only include actual interactions
const seenIds = new Set<string>();
interactionsWithMe = interactionEvents
.filter(e => {
if (seenIds.has(e.id)) return false;
seenIds.add(e.id);
// Check if it's a reply to current user's post
const eTag = e.tags.find(t => t[0] === 'e');
const isReplyToCurrentUser = eTag && currentUserPostIds.has(eTag[1]);
// Check if it mentions current user
const pTag = e.tags.find(t => t[0] === 'p' && t[1] === currentUserPubkey);
const mentionsCurrentUser = !!pTag;
return isReplyToCurrentUser || mentionsCurrentUser;
})
.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error loading interactions with me:', error);
interactionsWithMe = [];
}
}
onMount(async () => {
await nostrClient.initialize();
@ -74,6 +147,54 @@ @@ -74,6 +147,54 @@
return null;
}
/**
* Validate NIP-05 address against well-known.json
*/
async function validateNIP05(nip05: string, expectedPubkey: string) {
// Mark as checking
nip05Validations.set(nip05, null);
try {
// Parse NIP-05: format is "local@domain.com"
const [localPart, domain] = nip05.split('@');
if (!localPart || !domain) {
nip05Validations.set(nip05, false);
return;
}
// Fetch well-known JSON
// NIP-05 spec: https://[domain]/.well-known/nostr.json?name=[local]
const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
nip05Validations.set(nip05, false);
return;
}
const data = await response.json();
// Check if the response contains the expected pubkey
// NIP-05 format: { "names": { "local": "hex-pubkey" } }
if (data.names && data.names[localPart]) {
const verifiedPubkey = data.names[localPart].toLowerCase();
const expected = expectedPubkey.toLowerCase();
nip05Validations.set(nip05, verifiedPubkey === expected);
} else {
nip05Validations.set(nip05, false);
}
} catch (error) {
console.error('Error validating NIP-05:', nip05, error);
nip05Validations.set(nip05, false);
}
}
async function loadProfile() {
const param = $page.params.pubkey;
if (!param) {
@ -101,6 +222,16 @@ @@ -101,6 +222,16 @@
profile = profileData;
console.log('Profile loaded:', profileData);
// Validate NIP-05 addresses (async, don't wait)
if (profileData?.nip05 && profileData.nip05.length > 0) {
for (const nip05 of profileData.nip05) {
// Validate in background - don't block page load
validateNIP05(nip05, pubkey).catch(err => {
console.error('NIP-05 validation error:', err);
});
}
}
// Load user status
const status = await fetchUserStatus(pubkey);
userStatus = status;
@ -122,13 +253,25 @@ @@ -122,13 +253,25 @@
{ useCache: true, cacheResults: true }
);
// Filter to only include actual replies (have e tag pointing to user's posts)
// AND exclude self-replies (where author is the same as the profile owner)
const userPostIds = new Set(posts.map(p => p.id));
responses = responseEvents
.filter(e => {
// Exclude self-replies
if (e.pubkey === pubkey) {
return false;
}
const eTag = e.tags.find(t => t[0] === 'e');
return eTag && userPostIds.has(eTag[1]);
})
.sort((a, b) => b.created_at - a.created_at);
// Load "Interactions with me" if user is logged in and viewing another user's profile
if (currentUserPubkey && currentUserPubkey !== pubkey) {
await loadInteractionsWithMe(pubkey, currentUserPubkey);
} else {
interactionsWithMe = [];
}
} catch (error) {
console.error('Error loading profile:', error);
// Set loading to false even on error so UI can show error state
@ -181,8 +324,16 @@ @@ -181,8 +324,16 @@
{#if profile.nip05 && profile.nip05.length > 0}
<div class="nip05 mb-2">
{#each profile.nip05 as nip05}
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
{@const isValid = nip05Validations.get(nip05)}
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light mr-2">
{nip05}
{#if isValid === true}
<span class="nip05-valid" title="NIP-05 verified"></span>
{:else if isValid === false}
<span class="nip05-invalid" title="NIP-05 verification failed"></span>
{:else}
<span class="nip05-checking" title="Verifying NIP-05..."></span>
{/if}
</span>
{/each}
</div>
@ -204,6 +355,14 @@ @@ -204,6 +355,14 @@
>
Responses ({responses.length})
</button>
{#if currentUserPubkey && currentUserPubkey !== decodePubkey($page.params.pubkey)}
<button
onclick={() => activeTab = 'interactions'}
class="px-4 py-2 font-semibold {activeTab === 'interactions' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
>
Interactions with me ({interactionsWithMe.length})
</button>
{/if}
</div>
{#if activeTab === 'posts'}
@ -216,7 +375,7 @@ @@ -216,7 +375,7 @@
{/each}
</div>
{/if}
{:else}
{:else if activeTab === 'responses'}
{#if responses.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No responses yet.</p>
{:else}
@ -226,6 +385,16 @@ @@ -226,6 +385,16 @@
{/each}
</div>
{/if}
{:else if activeTab === 'interactions'}
{#if interactionsWithMe.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No interactions with you yet.</p>
{:else}
<div class="interactions-list">
{#each interactionsWithMe as interaction (interaction.id)}
<FeedPost post={interaction} />
{/each}
</div>
{/if}
{/if}
</div>
{:else}
@ -243,4 +412,32 @@ @@ -243,4 +412,32 @@
.profile-picture {
object-fit: cover;
}
.nip05-valid {
color: #60a5fa;
margin-left: 0.25rem;
font-weight: bold;
}
.nip05-invalid {
color: #ef4444;
margin-left: 0.25rem;
font-weight: bold;
}
.nip05-checking {
color: #9ca3af;
margin-left: 0.25rem;
display: inline-block;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

23
src/lib/modules/threads/ThreadView.svelte

@ -38,8 +38,12 @@ @@ -38,8 +38,12 @@
async function loadThread() {
loading = true;
try {
const relays = relayManager.getThreadReadRelays();
const event = await nostrClient.getEventById(threadId, relays);
// Try multiple relay sets to find the event
const threadRelays = relayManager.getThreadReadRelays();
const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...threadRelays, ...feedRelays])];
const event = await nostrClient.getEventById(threadId, allRelays);
thread = event;
} catch (error) {
console.error('Error loading thread:', error);
@ -50,8 +54,17 @@ @@ -50,8 +54,17 @@
function getTitle(): string {
if (!thread) return '';
const titleTag = thread.tags.find((t) => t[0] === 'title');
return titleTag?.[1] || 'Untitled';
// For kind 11 threads, use title tag
if (thread.kind === 11) {
const titleTag = thread.tags.find((t) => t[0] === 'title');
return titleTag?.[1] || 'Untitled';
}
// For other kinds, show kind description or first line of content
const firstLine = thread.content.split('\n')[0].trim();
if (firstLine.length > 0 && firstLine.length < 100) {
return firstLine;
}
return getKindInfo(thread.kind).description;
}
function getTopics(): string[] {
@ -150,7 +163,7 @@ @@ -150,7 +163,7 @@
{/if}
<div class="comments-section">
<CommentThread threadId={thread.id} />
<CommentThread threadId={thread.id} event={thread} />
</div>
<div class="kind-badge">

64
src/routes/thread/[id]/+page.svelte

@ -4,18 +4,76 @@ @@ -4,18 +4,76 @@
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { nip19 } from 'nostr-tools';
let decodedEventId = $state<string | null>(null);
/**
* Decode route parameter to event hex ID
* Supports: hex event id, note, nevent, naddr
*/
function decodeEventId(param: string): string | null {
if (!param) return null;
// Check if it's already a hex event ID (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(param)) {
return param.toLowerCase();
}
// Check if it's a bech32 encoded format (note, nevent, naddr)
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(param)) {
try {
const decoded = nip19.decode(param);
if (decoded.type === 'note') {
return String(decoded.data);
} else if (decoded.type === 'nevent') {
// nevent contains event id and optional relays
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
return String(decoded.data.id);
}
} else if (decoded.type === 'naddr') {
// naddr is for parameterized replaceable events (kind + pubkey + d tag)
// We need to fetch the event using these parameters, then get its id
// For now, return a special marker that ThreadView can handle
// naddr format: { kind, pubkey, identifier (d tag), relays? }
if (decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data) {
// Store naddr data for fetching - we'll handle this in ThreadView
// For now, return null and ThreadView will need to fetch by kind+pubkey+d
console.warn('naddr requires fetching event by kind+pubkey+d, not yet fully supported');
return null;
}
}
} catch (error) {
console.error('Error decoding bech32:', error);
return null;
}
}
return null;
}
onMount(async () => {
await nostrClient.initialize();
if ($page.params.id) {
decodedEventId = decodeEventId($page.params.id);
}
});
$effect(() => {
if ($page.params.id) {
decodedEventId = decodeEventId($page.params.id);
}
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
{#if $page.params.id}
<ThreadView threadId={$page.params.id} />
{#if decodedEventId}
<ThreadView threadId={decodedEventId} />
{:else if $page.params.id}
<p class="text-fog-text dark:text-fog-dark-text">Invalid event ID format. Supported: hex event ID, note, nevent, or naddr</p>
{:else}
<p class="text-fog-text dark:text-fog-dark-text">Thread ID required</p>
<p class="text-fog-text dark:text-fog-dark-text">Event ID required</p>
{/if}
</main>

Loading…
Cancel
Save