Browse Source

fix profile feeds

Nostr-Signature: 33f33d76f6c79e68fdab72c8fdfc7e1f0ecc53a879a7f5aef02481f17384a06f 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 8f9056eab081d66edb693eb35a2e400368aa897746b97ca40a216604dc14ee877eb7f4f16dd2eeac257025b3adfe82e23734c7c106b6cec5e8a1ca661c872cc5
main
Silberengel 3 weeks ago
parent
commit
d1bcddef70
  1. 1
      nostr/commit-signatures.jsonl
  2. 23
      src/lib/components/UserBadge.svelte
  3. 2
      src/lib/types/nostr.ts
  4. 495
      src/routes/repos/[npub]/[repo]/+page.svelte
  5. 1249
      src/routes/users/[npub]/+page.svelte

1
nostr/commit-signatures.jsonl

@ -38,3 +38,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771664126,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","update profile page, dashboard, and connections"]],"content":"Signed commit: update profile page, dashboard, and connections","id":"862b888e52bf4fc3e53c80afd9f301b22ce674366f48d006bca520479394c0f9","sig":"c2e895f67ff5a68e87dcdc54a0312e169f4729a05a62f1ffbe92afd6e57b7d232b36ef4291c07969e531cdc4f22f5ac32723a2aecc57a0b613b945217ecc651a"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771664126,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","update profile page, dashboard, and connections"]],"content":"Signed commit: update profile page, dashboard, and connections","id":"862b888e52bf4fc3e53c80afd9f301b22ce674366f48d006bca520479394c0f9","sig":"c2e895f67ff5a68e87dcdc54a0312e169f4729a05a62f1ffbe92afd6e57b7d232b36ef4291c07969e531cdc4f22f5ac32723a2aecc57a0b613b945217ecc651a"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771664339,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","added lightning address copy button"]],"content":"Signed commit: added lightning address copy button","id":"f0973d13a903f64895d265643390fe54bd86fe492a53c3ffea303dad8cf8a2f6","sig":"8c98969c5755bf8742733e05ca4be53f4f3ba276a2445ee7b903e443947fc53808b046c188dd91f26b6dcaecbe93585e1f2539855c8eba57e17a915e81bfa2d4"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771664339,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","added lightning address copy button"]],"content":"Signed commit: added lightning address copy button","id":"f0973d13a903f64895d265643390fe54bd86fe492a53c3ffea303dad8cf8a2f6","sig":"8c98969c5755bf8742733e05ca4be53f4f3ba276a2445ee7b903e443947fc53808b046c188dd91f26b6dcaecbe93585e1f2539855c8eba57e17a915e81bfa2d4"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771668002,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","finish profile page"]],"content":"Signed commit: finish profile page","id":"8a5aed2f8ac370f781dca9db96ade991c18b7cc3b0d27149d9e2741e8276f16f","sig":"16e9a9242f7c22dab8e37fd9d618419b4d51d7c0156f52c1289e275d2528312f4006696473c6836b5a661425fe0412fe54127291fb9b0d14777f93c8228cffb0"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771668002,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","finish profile page"]],"content":"Signed commit: finish profile page","id":"8a5aed2f8ac370f781dca9db96ade991c18b7cc3b0d27149d9e2741e8276f16f","sig":"16e9a9242f7c22dab8e37fd9d618419b4d51d7c0156f52c1289e275d2528312f4006696473c6836b5a661425fe0412fe54127291fb9b0d14777f93c8228cffb0"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771669826,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","user badge is a universal hyperlink to the profile page"]],"content":"Signed commit: user badge is a universal hyperlink to the profile page","id":"973a406714e586037d81cca323024ff5e2cc1fbaeda8846f6f2994c3829c4fe0","sig":"e7a58526a3786fc1b9ab1f957c87c13a42d3c2cc95effcf4ce4f4710e01ecc45fcff3ca542c5fa223961d7b99fe336a2851c133aebe3bfc1a591ffe1c34b221a"}

23
src/lib/components/UserBadge.svelte

@ -13,27 +13,26 @@
let { pubkey, disableLink = false }: Props = $props(); let { pubkey, disableLink = false }: Props = $props();
// Convert pubkey to npub for navigation // Convert pubkey to npub for navigation (reactive)
function getNpub(): string { const profileUrl = $derived.by(() => {
try { try {
// Check if already npub format // Check if already npub format
try { try {
const decoded = nip19.decode(pubkey); const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') { if (decoded.type === 'npub') {
return pubkey; return `/users/${pubkey}`;
} }
} catch { } catch {
// Not an npub, continue to encode // Not an npub, continue to encode
} }
// Convert hex pubkey to npub // Convert hex pubkey to npub
return nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
return `/users/${npub}`;
} catch { } catch {
// If all fails, return as-is (will be handled by route) // If all fails, return as-is (will be handled by route)
return pubkey; return `/users/${pubkey}`;
} }
} });
const profileUrl = `/users/${getNpub()}`;
let userProfile = $state<{ name?: string; picture?: string } | null>(null); let userProfile = $state<{ name?: string; picture?: string } | null>(null);
let loading = $state(true); let loading = $state(true);
@ -194,7 +193,13 @@
<span class="user-badge-name">{truncateHandle(userProfile?.name)}</span> <span class="user-badge-name">{truncateHandle(userProfile?.name)}</span>
</div> </div>
{:else} {:else}
<a href={profileUrl} class="user-badge"> <a
href={profileUrl}
class="user-badge"
onclick={(e) => {
e.stopPropagation();
}}
>
{#if userProfile?.picture} {#if userProfile?.picture}
<img src={userProfile.picture} alt="Profile" class="user-badge-avatar" /> <img src={userProfile.picture} alt="Profile" class="user-badge-avatar" />
{:else} {:else}

2
src/lib/types/nostr.ts

@ -58,6 +58,8 @@ export const KIND = {
PUBLIC_MESSAGE: 24, // NIP-24: Public message (direct chat) PUBLIC_MESSAGE: 24, // NIP-24: Public message (direct chat)
PROFILE_METADATA: 0, // NIP-01: User metadata PROFILE_METADATA: 0, // NIP-01: User metadata
REPOST: 6, // NIP-18: Repost REPOST: 6, // NIP-18: Repost
ZAP_REQUEST: 9734, // NIP-57: Lightning zap request
ZAP_RECEIPT: 9735, // NIP-57: Lightning zap receipt
} as const; } as const;
/** /**

495
src/routes/repos/[npub]/[repo]/+page.svelte

@ -364,6 +364,302 @@
}>>([]); }>>([]);
let loadingDiscussions = $state(false); let loadingDiscussions = $state(false);
// Discussion events cache for reply/quote blurbs
let discussionEvents = $state<Map<string, NostrEvent>>(new Map());
// Nostr link cache for embedded events and profiles
let nostrLinkEvents = $state<Map<string, NostrEvent>>(new Map());
let nostrLinkProfiles = $state<Map<string, string>>(new Map()); // npub -> pubkey hex
// Parse nostr: links from content and extract IDs/pubkeys
function parseNostrLinks(content: string): Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> {
const links: Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> = [];
const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|profile1)[a-zA-Z0-9]+/g;
let match;
while ((match = nostrLinkRegex.exec(content)) !== null) {
const fullMatch = match[0];
const prefix = match[1];
let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile';
if (prefix === 'nevent1') type = 'nevent';
else if (prefix === 'naddr1') type = 'naddr';
else if (prefix === 'note1') type = 'note1';
else if (prefix === 'npub1') type = 'npub';
else if (prefix === 'profile1') type = 'profile';
else continue;
links.push({
type,
value: fullMatch,
start: match.index,
end: match.index + fullMatch.length
});
}
return links;
}
// Load events/profiles from nostr: links
async function loadNostrLinks(content: string) {
const links = parseNostrLinks(content);
if (links.length === 0) return;
const eventIds: string[] = [];
const aTags: string[] = [];
const npubs: string[] = [];
for (const link of links) {
try {
if (link.type === 'nevent' || link.type === 'note1') {
const decoded = nip19.decode(link.value.replace('nostr:', ''));
if (decoded.type === 'nevent') {
eventIds.push(decoded.data.id);
} else if (decoded.type === 'note') {
eventIds.push(decoded.data as string);
}
} else if (link.type === 'naddr') {
const decoded = nip19.decode(link.value.replace('nostr:', ''));
if (decoded.type === 'naddr') {
const aTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`;
aTags.push(aTag);
}
} else if (link.type === 'npub' || link.type === 'profile') {
const decoded = nip19.decode(link.value.replace('nostr:', ''));
if (decoded.type === 'npub') {
npubs.push(link.value);
nostrLinkProfiles.set(link.value, decoded.data as string);
}
}
} catch {
// Invalid nostr link, skip
}
}
// Fetch events
if (eventIds.length > 0) {
try {
const events = await Promise.race([
nostrClient.fetchEvents([{ ids: eventIds, limit: eventIds.length }]),
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000))
]);
for (const event of events) {
nostrLinkEvents.set(event.id, event);
}
} catch {
// Ignore fetch errors
}
}
// Fetch a-tag events
if (aTags.length > 0) {
for (const aTag of aTags) {
const parts = aTag.split(':');
if (parts.length === 3) {
try {
const kind = parseInt(parts[0]);
const pubkey = parts[1];
const dTag = parts[2];
const events = await Promise.race([
nostrClient.fetchEvents([{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }]),
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000))
]);
if (events.length > 0) {
nostrLinkEvents.set(events[0].id, events[0]);
}
} catch {
// Ignore fetch errors
}
}
}
}
}
// Get event from nostr: link
function getEventFromNostrLink(link: string): NostrEvent | undefined {
try {
if (link.startsWith('nostr:nevent1') || link.startsWith('nostr:note1')) {
const decoded = nip19.decode(link.replace('nostr:', ''));
if (decoded.type === 'nevent') {
return nostrLinkEvents.get(decoded.data.id);
} else if (decoded.type === 'note') {
return nostrLinkEvents.get(decoded.data as string);
}
} else if (link.startsWith('nostr:naddr1')) {
const decoded = nip19.decode(link.replace('nostr:', ''));
if (decoded.type === 'naddr') {
const eventId = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`;
return Array.from(nostrLinkEvents.values()).find(e => {
const dTag = e.tags.find(t => t[0] === 'd')?.[1];
return e.kind === decoded.data.kind &&
e.pubkey === decoded.data.pubkey &&
dTag === decoded.data.identifier;
});
}
}
} catch {
// Invalid link
}
return undefined;
}
// Get pubkey from nostr: npub/profile link
function getPubkeyFromNostrLink(link: string): string | undefined {
return nostrLinkProfiles.get(link);
}
// Process content with nostr links into parts for rendering
function processContentWithNostrLinks(content: string): Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> {
const links = parseNostrLinks(content);
if (links.length === 0) {
return [{ type: 'text', value: content }];
}
const parts: Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> = [];
let lastIndex = 0;
for (const link of links) {
// Add text before link
if (link.start > lastIndex) {
const textPart = content.slice(lastIndex, link.start);
if (textPart) {
parts.push({ type: 'text', value: textPart });
}
}
// Add link
const event = getEventFromNostrLink(link.value);
const pubkey = getPubkeyFromNostrLink(link.value);
if (event) {
parts.push({ type: 'event', value: link.value, event });
} else if (pubkey) {
parts.push({ type: 'profile', value: link.value, pubkey });
} else {
parts.push({ type: 'placeholder', value: link.value });
}
lastIndex = link.end;
}
// Add remaining text
if (lastIndex < content.length) {
const textPart = content.slice(lastIndex);
if (textPart) {
parts.push({ type: 'text', value: textPart });
}
}
return parts;
}
// Load full events for discussions and comments to get tags for blurbs
async function loadDiscussionEvents(discussionsList: typeof discussions) {
const eventIds = new Set<string>();
// Collect all event IDs
for (const discussion of discussionsList) {
if (discussion.id) {
eventIds.add(discussion.id);
}
if (discussion.comments) {
for (const comment of discussion.comments) {
if (comment.id) {
eventIds.add(comment.id);
}
if (comment.replies) {
for (const reply of comment.replies) {
if (reply.id) {
eventIds.add(reply.id);
}
if (reply.replies) {
for (const nestedReply of reply.replies) {
if (nestedReply.id) {
eventIds.add(nestedReply.id);
}
}
}
}
}
}
}
}
if (eventIds.size === 0) return;
try {
const events = await Promise.race([
nostrClient.fetchEvents([{ ids: Array.from(eventIds), limit: eventIds.size }]),
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000))
]);
for (const event of events) {
discussionEvents.set(event.id, event);
}
} catch {
// Ignore fetch errors
}
}
// Get discussion event by ID
function getDiscussionEvent(eventId: string): NostrEvent | undefined {
return discussionEvents.get(eventId);
}
// Get referenced event from discussion event (e-tag, a-tag, q-tag)
function getReferencedEventFromDiscussion(event: NostrEvent): NostrEvent | undefined {
// Check e-tag
const eTag = event.tags.find(t => t[0] === 'e' && t[1])?.[1];
if (eTag) {
const referenced = discussionEvents.get(eTag);
if (referenced) return referenced;
}
// Check a-tag
const aTag = event.tags.find(t => t[0] === 'a' && t[1])?.[1];
if (aTag) {
const parts = aTag.split(':');
if (parts.length === 3) {
const kind = parseInt(parts[0]);
const pubkey = parts[1];
const dTag = parts[2];
return Array.from(discussionEvents.values()).find(e =>
e.kind === kind &&
e.pubkey === pubkey &&
e.tags.find(t => t[0] === 'd' && t[1] === dTag)
);
}
}
// Check q-tag
const qTag = event.tags.find(t => t[0] === 'q' && t[1])?.[1];
if (qTag) {
return discussionEvents.get(qTag);
}
return undefined;
}
// Format time for discussions
function formatDiscussionTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
// Create a nostrClient instance for fetching events
let nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
// README // README
let readmeContent = $state<string | null>(null); let readmeContent = $state<string | null>(null);
let readmePath = $state<string | null>(null); let readmePath = $state<string | null>(null);
@ -913,6 +1209,37 @@
pubkey: entry.pubkey, pubkey: entry.pubkey,
comments: entry.comments comments: entry.comments
})); }));
// Fetch full events for discussions and comments to get tags for blurbs
await loadDiscussionEvents(discussions);
// Fetch nostr: links from discussion content
for (const discussion of discussions) {
if (discussion.content) {
await loadNostrLinks(discussion.content);
}
if (discussion.comments) {
for (const comment of discussion.comments) {
if (comment.content) {
await loadNostrLinks(comment.content);
}
if (comment.replies) {
for (const reply of comment.replies) {
if (reply.content) {
await loadNostrLinks(reply.content);
}
if (reply.replies) {
for (const nestedReply of reply.replies) {
if (nestedReply.content) {
await loadNostrLinks(nestedReply.content);
}
}
}
}
}
}
}
}
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : 'Failed to load discussions'; error = err instanceof Error ? err.message : 'Failed to load discussions';
console.error('Error loading discussions:', err); console.error('Error loading discussions:', err);
@ -1046,6 +1373,7 @@
// Get repo announcement to get the repo address and relays // Get repo announcement to get the repo address and relays
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); const client = new NostrClient(DEFAULT_NOSTR_RELAYS);
nostrClient = client; // Store for use in other functions
const events = await client.fetchEvents([ const events = await client.fetchEvents([
{ {
kinds: [KIND.REPO_ANNOUNCEMENT], kinds: [KIND.REPO_ANNOUNCEMENT],
@ -4216,9 +4544,41 @@
</button> </button>
{/if} {/if}
</div> </div>
<div class="comment-content"> {#if true}
<p>{comment.content}</p> {@const commentEvent = getDiscussionEvent(comment.id)}
</div> {@const referencedEvent = commentEvent ? getReferencedEventFromDiscussion(commentEvent) : undefined}
{@const parts = processContentWithNostrLinks(comment.content)}
<div class="comment-content">
{#if referencedEvent}
<div class="referenced-event">
<div class="referenced-event-header">
<UserBadge pubkey={referencedEvent.pubkey} disableLink={true} />
<span class="referenced-event-time">{formatDiscussionTime(referencedEvent.created_at)}</span>
</div>
<div class="referenced-event-content">{referencedEvent.content || '(No content)'}</div>
</div>
{/if}
<div>
{#each parts as part}
{#if part.type === 'text'}
<span>{part.value}</span>
{:else if part.type === 'event' && part.event}
<div class="nostr-link-event">
<div class="nostr-link-event-header">
<UserBadge pubkey={part.event.pubkey} disableLink={true} />
<span class="nostr-link-event-time">{formatDiscussionTime(part.event.created_at)}</span>
</div>
<div class="nostr-link-event-content">{part.event.content || '(No content)'}</div>
</div>
{:else if part.type === 'profile' && part.pubkey}
<UserBadge pubkey={part.pubkey} />
{:else}
<span class="nostr-link-placeholder">{part.value}</span>
{/if}
{/each}
</div>
</div>
{/if}
{#if comment.replies && comment.replies.length > 0} {#if comment.replies && comment.replies.length > 0}
<div class="nested-replies"> <div class="nested-replies">
{#each comment.replies as reply} {#each comment.replies as reply}
@ -4301,9 +4661,41 @@
</button> </button>
{/if} {/if}
</div> </div>
<div class="comment-content"> {#if true}
<p>{comment.content}</p> {@const commentEvent = getDiscussionEvent(comment.id)}
</div> {@const referencedEvent = commentEvent ? getReferencedEventFromDiscussion(commentEvent) : undefined}
{@const parts = processContentWithNostrLinks(comment.content)}
<div class="comment-content">
{#if referencedEvent}
<div class="referenced-event">
<div class="referenced-event-header">
<UserBadge pubkey={referencedEvent.pubkey} disableLink={true} />
<span class="referenced-event-time">{formatDiscussionTime(referencedEvent.created_at)}</span>
</div>
<div class="referenced-event-content">{referencedEvent.content || '(No content)'}</div>
</div>
{/if}
<div>
{#each parts as part}
{#if part.type === 'text'}
<span>{part.value}</span>
{:else if part.type === 'event' && part.event}
<div class="nostr-link-event">
<div class="nostr-link-event-header">
<UserBadge pubkey={part.event.pubkey} disableLink={true} />
<span class="nostr-link-event-time">{formatDiscussionTime(part.event.created_at)}</span>
</div>
<div class="nostr-link-event-content">{part.event.content || '(No content)'}</div>
</div>
{:else if part.type === 'profile' && part.pubkey}
<UserBadge pubkey={part.pubkey} />
{:else}
<span class="nostr-link-placeholder">{part.value}</span>
{/if}
{/each}
</div>
</div>
{/if}
{#if comment.replies && comment.replies.length > 0} {#if comment.replies && comment.replies.length > 0}
<div class="nested-replies"> <div class="nested-replies">
{#each comment.replies as reply} {#each comment.replies as reply}
@ -6635,6 +7027,97 @@
line-height: 1.5; line-height: 1.5;
} }
.referenced-event {
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--bg-primary);
border-left: 3px solid var(--border-color);
border-radius: 0.5rem;
font-size: 0.875rem;
}
:global([data-theme="light"]) .referenced-event {
background: #e8e8e8;
}
:global([data-theme="dark"]) .referenced-event {
background: rgba(0, 0, 0, 0.2);
}
:global([data-theme="black"]) .referenced-event {
background: #0a0a0a;
}
.referenced-event-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.referenced-event-time {
font-size: 0.75rem;
color: var(--text-muted);
}
.referenced-event-content {
color: var(--text-secondary);
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.5;
max-height: 10rem;
overflow: hidden;
text-overflow: ellipsis;
}
.nostr-link-event {
margin: 0.75rem 0;
padding: 0.75rem;
background: var(--bg-primary);
border-left: 3px solid var(--border-color);
border-radius: 0.5rem;
font-size: 0.875rem;
}
:global([data-theme="light"]) .nostr-link-event {
background: #e8e8e8;
}
:global([data-theme="dark"]) .nostr-link-event {
background: rgba(0, 0, 0, 0.2);
}
:global([data-theme="black"]) .nostr-link-event {
background: #0a0a0a;
}
.nostr-link-event-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.nostr-link-event-time {
font-size: 0.75rem;
color: var(--text-muted);
}
.nostr-link-event-content {
color: var(--text-secondary);
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.5;
max-height: 10rem;
overflow: hidden;
text-overflow: ellipsis;
}
.nostr-link-placeholder {
color: var(--text-muted);
font-style: italic;
}
.nested-replies { .nested-replies {
margin-left: 2rem; margin-left: 2rem;
margin-top: 0.75rem; margin-top: 0.75rem;

1249
src/routes/users/[npub]/+page.svelte

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save