From 40ae1e358851d34ea7e9601d9b1bd83fd955b01f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 21 Feb 2026 11:00:02 +0100 Subject: [PATCH] finish profile page Nostr-Signature: 8a5aed2f8ac370f781dca9db96ade991c18b7cc3b0d27149d9e2741e8276f16f 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 16e9a9242f7c22dab8e37fd9d618419b4d51d7c0156f52c1289e275d2528312f4006696473c6836b5a661425fe0412fe54127291fb9b0d14777f93c8228cffb0 --- nostr/commit-signatures.jsonl | 1 + .../services/nostr/persistent-event-cache.ts | 4 +- src/routes/repos/+page.svelte | 323 ++++++ src/routes/users/[npub]/+page.svelte | 939 ++++++++++++++++-- 4 files changed, 1198 insertions(+), 69 deletions(-) diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 741e79f..c162929 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -36,3 +36,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771626015,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"f5bde3d9199d8cbacca481959663f1e14c43e143ef2b5686502559408e1c526b","sig":"3ed47cd283746d290d8609cbfdefbcee31a19d8e43e1a6ebf5a2829904000d79b83d3235296af4b5f7b555051214fbf2fa5c7a6d7986dca853112bb4e122a6d5"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771627873,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"5726811907af73d3b478f3938cdc6421200040542cb1a586b3497c56a24c33cb","sig":"3833d05ba5a34cad78caacbc8382fcd7a85c60b56dd3b18f9a5c68c890d7a611fa6b885ef02be465f541629b0afaeec0e9d57d3b00db332c5c8ae42fd72fc83d"} {"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"} diff --git a/src/lib/services/nostr/persistent-event-cache.ts b/src/lib/services/nostr/persistent-event-cache.ts index 11e533d..b5de28f 100644 --- a/src/lib/services/nostr/persistent-event-cache.ts +++ b/src/lib/services/nostr/persistent-event-cache.ts @@ -41,8 +41,8 @@ const STORE_FILTERS = 'filters'; const STORE_PROFILES = 'profiles'; const REPLACEABLE_KINDS = [0, 3, 10002]; // Profile, Contacts, Relay List -const DEFAULT_TTL = 5 * 60 * 1000; // 5 minutes -const PROFILE_TTL = 30 * 60 * 1000; // 30 minutes +const DEFAULT_TTL = 30 * 60 * 1000; // 30 minutes (increased for better performance) +const PROFILE_TTL = 60 * 60 * 1000; // 60 minutes (increased for profiles) interface CachedEvent { event: NostrEvent; diff --git a/src/routes/repos/+page.svelte b/src/routes/repos/+page.svelte index 912a3e4..05e6654 100644 --- a/src/routes/repos/+page.svelte +++ b/src/routes/repos/+page.svelte @@ -35,6 +35,13 @@ let myRepos = $state>([]); let loadingMyRepos = $state(false); + // Most Favorited Repositories + let mostFavoritedRepos = $state>([]); + let loadingMostFavorited = $state(false); + let mostFavoritedPage = $state(0); + let mostFavoritedCache: { data: typeof mostFavoritedRepos; timestamp: number } | null = null; + const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; const forkCountService = new ForkCountService(DEFAULT_NOSTR_RELAYS); @@ -44,6 +51,7 @@ await loadRepos(); await loadUserAndContacts(); await loadMyRepos(); + await loadMostFavoritedRepos(); }); // Reload repos when page becomes visible (e.g., after returning from another page) @@ -411,6 +419,133 @@ goto(`/signup?npub=${encodeURIComponent(npub)}&repo=${encodeURIComponent(repo)}`); } + async function loadMostFavoritedRepos() { + // Check cache first + if (mostFavoritedCache && Date.now() - mostFavoritedCache.timestamp < CACHE_TTL) { + updateMostFavoritedPage(); + return; + } + + loadingMostFavorited = true; + try { + // Fetch up to 1000 bookmark events (kind 10003) + const bookmarkEvents = await nostrClient.fetchEvents([ + { + kinds: [KIND.BOOKMARKS], + limit: 1000 + } + ]); + + // Count how many times each repo a-tag appears + const repoCounts = new Map(); + + for (const bookmark of bookmarkEvents) { + for (const tag of bookmark.tags) { + if (tag[0] === 'a' && tag[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)) { + const aTag = tag[1]; + const current = repoCounts.get(aTag) || { count: 0, aTag }; + current.count++; + repoCounts.set(aTag, current); + } + } + } + + // Convert to array and sort by count + const repoCountsArray = Array.from(repoCounts.entries()) + .map(([aTag, data]) => ({ aTag, count: data.count })) + .sort((a, b) => b.count - a.count); + + // Fetch repo announcements for the top repos + const topRepos: Array<{ event: NostrEvent; npub: string; repoName: string; favoriteCount: number }> = []; + + for (const { aTag, count } of repoCountsArray.slice(0, 100)) { + // Parse a-tag: 30617:pubkey:d-tag + const parts = aTag.split(':'); + if (parts.length >= 3) { + const pubkey = parts[1]; + const dTag = parts[2]; + + // Fetch the repo announcement + const announcements = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [pubkey], + '#d': [dTag], + limit: 1 + } + ]); + + if (announcements.length > 0) { + try { + const npub = nip19.npubEncode(pubkey); + topRepos.push({ + event: announcements[0], + npub, + repoName: dTag, + favoriteCount: count + }); + } catch (err) { + console.warn('Failed to encode npub for favorited repo:', err); + } + } + } + } + + // Cache the results + mostFavoritedCache = { + data: topRepos, + timestamp: Date.now() + }; + + updateMostFavoritedPage(); + } catch (err) { + console.error('Failed to load most favorited repos:', err); + } finally { + loadingMostFavorited = false; + } + } + + function updateMostFavoritedPage() { + if (!mostFavoritedCache) return; + + const start = mostFavoritedPage * 10; + const end = start + 10; + mostFavoritedRepos = mostFavoritedCache.data.slice(start, end); + } + + function nextMostFavoritedPage() { + if (!mostFavoritedCache) return; + const maxPage = Math.ceil(mostFavoritedCache.data.length / 10) - 1; + if (mostFavoritedPage < maxPage) { + mostFavoritedPage++; + updateMostFavoritedPage(); + } + } + + function prevMostFavoritedPage() { + if (mostFavoritedPage > 0) { + mostFavoritedPage--; + updateMostFavoritedPage(); + } + } + + // Background refresh of cache + async function refreshMostFavoritedCache() { + if (!mostFavoritedCache || Date.now() - mostFavoritedCache.timestamp >= CACHE_TTL) { + await loadMostFavoritedRepos(); + } + } + + // Start background refresh + $effect(() => { + if (typeof window !== 'undefined') { + const interval = setInterval(() => { + refreshMostFavoritedCache().catch(err => console.warn('Background refresh failed:', err)); + }, CACHE_TTL); + return () => clearInterval(interval); + } + }); + async function loadForkCounts(repoEvents: NostrEvent[]) { const counts = new Map(); @@ -620,6 +755,58 @@ {/if} + +
+
+

Most Favorited Repositories

+ {#if loadingMostFavorited} + Loading... + {/if} +
+ {#if loadingMostFavorited && mostFavoritedRepos.length === 0} +
Loading most favorited repositories...
+ {:else if mostFavoritedRepos.length === 0} +
No favorited repositories found.
+ {:else} + + {#if mostFavoritedCache && mostFavoritedCache.data.length > 10} + + {/if} + {/if} +
+

Repositories on {$page.data.gitDomain || 'localhost:6543'}

+ + diff --git a/src/routes/users/[npub]/+page.svelte b/src/routes/users/[npub]/+page.svelte index 3a0cd81..2bd2c02 100644 --- a/src/routes/users/[npub]/+page.svelte +++ b/src/routes/users/[npub]/+page.svelte @@ -13,6 +13,7 @@ import { userStore } from '$lib/stores/user-store.js'; import { fetchUserProfile, extractProfileData } from '$lib/utils/user-profile.js'; import { combineRelays } from '$lib/config.js'; + import { KIND } from '$lib/types/nostr.js'; const npub = ($page.params as { npub?: string }).npub || ''; @@ -22,21 +23,30 @@ let profileOwnerPubkeyHex = $state(null); let viewerPubkeyHex = $state(null); let repos = $state([]); - let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null); + let ownedRepos = $state([]); + let maintainedRepos = $state([]); + let favoriteRepos = $state([]); + let userProfile = $state<{ name?: string; about?: string; picture?: string; banner?: string } | null>(null); let profileEvent = $state(null); let profileData = $state(null); - let profileTags = $state>([]); + let profileTags = $state>([]); let paymentTargets = $state>([]); // Messages - let activeTab = $state<'repos' | 'messages'>('repos'); + let activeTab = $state<'repos' | 'messages' | 'activity'>('repos'); let messages = $state([]); let loadingMessages = $state(false); + let messagesLoaded = $state(false); // Track if we've attempted to load messages let showSendMessageDialog = $state(false); let newMessageContent = $state(''); let sendingMessage = $state(false); let messagesService: PublicMessagesService | null = null; + // Activity + let activityEvents = $state([]); + let loadingActivity = $state(false); + let activityLoaded = $state(false); // Track if we've attempted to load activity + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const gitDomain = $page.data.gitDomain || 'localhost:6543'; @@ -52,11 +62,18 @@ // Load messages when tab is active $effect(() => { - if (activeTab === 'messages' && profileOwnerPubkeyHex && messages.length === 0 && !loadingMessages) { + if (activeTab === 'messages' && profileOwnerPubkeyHex && !messagesLoaded && !loadingMessages) { loadMessages(); } }); + // Load activity when tab is active + $effect(() => { + if (activeTab === 'activity' && profileOwnerPubkeyHex && !activityLoaded && !loadingActivity) { + loadActivity(); + } + }); + async function loadUserProfile() { loading = true; error = null; @@ -83,6 +100,11 @@ const data = await response.json(); repos = data.repos || []; + // Organize repos into owned, maintained, and favorites + if (profileOwnerPubkeyHex) { + await organizeRepos(); + } + // Load profile profileEvent = await fetchUserProfile(profileOwnerPubkeyHex, DEFAULT_NOSTR_RELAYS); @@ -96,20 +118,72 @@ profileData = null; } - // Extract tags - profileTags = profileEvent.tags - .filter(t => t.length > 0 && t[0]) - .map(t => ({ name: t[0], value: t.slice(1).join(', ') })); + // Extract tags - only bot, nip05, and website, grouped by tag name + const tagsToShow = new Set(['bot', 'nip05', 'website']); + const groupedTags = new Map(); + + for (const tag of profileEvent.tags) { + if (tag.length > 0 && tag[0] && tagsToShow.has(tag[0])) { + const tagName = tag[0]; + const values: string[] = []; + for (let i = 1; i < tag.length; i++) { + if (tag[i]) { + values.push(tag[i]); + } + } + if (values.length > 0) { + const existing = groupedTags.get(tagName) || []; + groupedTags.set(tagName, [...existing, ...values]); + } + } + } + + // Fallback to JSON content for missing tags (old-fashioned events) + if (profileData && typeof profileData === 'object') { + // Check for nip05 + if (!groupedTags.has('nip05') && profileData.nip05) { + const nip05Values = Array.isArray(profileData.nip05) ? profileData.nip05 : [profileData.nip05]; + groupedTags.set('nip05', nip05Values.filter(Boolean).map(String)); + } + + // Check for website + if (!groupedTags.has('website') && profileData.website) { + const websiteValues = Array.isArray(profileData.website) ? profileData.website : [profileData.website]; + groupedTags.set('website', websiteValues.filter(Boolean).map(String)); + } + + // Check for bot + if (!groupedTags.has('bot') && profileData.bot !== undefined) { + const botValue = profileData.bot === true || profileData.bot === 'true' || profileData.bot === '1' ? 'true' : String(profileData.bot); + groupedTags.set('bot', [botValue]); + } + } + + // Convert to array (nip05 verification happens asynchronously) + profileTags = []; + for (const [tagName, values] of groupedTags.entries()) { + if (tagName === 'nip05') { + // Initialize with unverified status, verify asynchronously + profileTags.push({ name: tagName, values, verified: new Array(values.length).fill(false) }); + } else { + profileTags.push({ name: tagName, values }); + } + } + + // Verify nip05 values asynchronously + verifyNip05Tags(); - // Extract profile fields + // Extract profile fields (with fallback to JSON content) const nameTag = profileEvent.tags.find(t => t[0] === 'name' || t[0] === 'display_name')?.[1]; const aboutTag = profileEvent.tags.find(t => t[0] === 'about')?.[1]; const pictureTag = profileEvent.tags.find(t => t[0] === 'picture' || t[0] === 'avatar')?.[1]; + const bannerTag = profileEvent.tags.find(t => t[0] === 'banner')?.[1]; userProfile = { name: nameTag || profileData?.display_name || profileData?.name, about: aboutTag || profileData?.about, - picture: pictureTag || profileData?.picture + picture: pictureTag || profileData?.picture, + banner: bannerTag || profileData?.banner }; } @@ -122,12 +196,18 @@ const lightningAddresses = new Set(); - // Extract from profile event + // Extract from profile event (tags first, then JSON content fallback) if (profileEvent) { + // Extract from tags const lud16Tags = profileEvent.tags.filter(t => t[0] === 'lud16').map(t => t[1]).filter(Boolean); lud16Tags.forEach(addr => lightningAddresses.add(addr.toLowerCase())); + + // Fallback to JSON content for lud16 (old-fashioned events) if (profileData?.lud16) { - lightningAddresses.add(profileData.lud16.toLowerCase()); + const lud16Values = Array.isArray(profileData.lud16) ? profileData.lud16 : [profileData.lud16]; + lud16Values.forEach((addr: any) => { + if (addr) lightningAddresses.add(String(addr).toLowerCase()); + }); } } @@ -169,8 +249,53 @@ } } + async function verifyNip05Tags() { + if (!profileOwnerPubkeyHex) return; + + // Find nip05 tags and verify them + const updatedTags = [...profileTags]; + for (let i = 0; i < updatedTags.length; i++) { + const tag = updatedTags[i]; + if (tag.name === 'nip05' && tag.verified) { + const verified: boolean[] = []; + for (const value of tag.values) { + try { + const { resolvePubkey } = await import('$lib/utils/pubkey-resolver.js'); + const resolvedPubkey = await resolvePubkey(value); + verified.push(resolvedPubkey === profileOwnerPubkeyHex); + } catch { + verified.push(false); + } + } + // Update the verified array + updatedTags[i] = { ...tag, verified }; + } + } + // Update profileTags to trigger reactivity + profileTags = updatedTags; + } + + // Shared function to filter out user's own events and write-proof messages + function shouldExcludeEvent(event: NostrEvent, userPubkey: string): boolean { + // Exclude write-proof kind 24 events + if (event.kind === KIND.PUBLIC_MESSAGE && event.content && event.content.includes('gitrepublic-write-proof')) { + return true; + } + + // Exclude user's own events (messages FROM the user) + // Note: We want to SHOW messages TO the user from other people, so we only exclude messages FROM the user + if (event.pubkey === userPubkey) { + return true; + } + + // Note: We don't exclude messages TO the user from other people - those should be shown + // The check for "messages to themselves" is already covered by the check above (event.pubkey === userPubkey) + + return false; + } + async function loadMessages() { - if (!profileOwnerPubkeyHex || loadingMessages) return; + if (!profileOwnerPubkeyHex || loadingMessages || messagesLoaded) return; loadingMessages = true; try { @@ -178,17 +303,24 @@ messagesService = new PublicMessagesService(DEFAULT_NOSTR_RELAYS); } const allMessages = await messagesService.getAllMessagesForUser(profileOwnerPubkeyHex, 100); - // Filter out gitrepublic-write-proof kind 24 events + // Filter out user's own messages and write-proof events messages = allMessages.filter(msg => { - // Skip kind 24 events that contain "gitrepublic-write-proof" in content - if (msg.kind === 24 && msg.content && msg.content.includes('gitrepublic-write-proof')) { - return false; - } - return true; + // Convert PublicMessage to NostrEvent-like structure for filtering + const eventLike: NostrEvent = { + id: msg.id, + pubkey: msg.pubkey, + created_at: msg.created_at, + kind: msg.kind, + tags: msg.tags, + content: msg.content, + sig: msg.sig || '' + }; + return !shouldExcludeEvent(eventLike, profileOwnerPubkeyHex || ''); }); } catch (err) { console.error('Error loading messages:', err); } finally { + messagesLoaded = true; // Mark as loaded to prevent infinite loop (even on error) loadingMessages = false; } } @@ -225,6 +357,7 @@ throw new Error('Failed to publish message to all relays'); } + messagesLoaded = false; // Reset flag to reload messages after sending await loadMessages(); showSendMessageDialog = false; newMessageContent = ''; @@ -246,6 +379,125 @@ return event.tags.find(t => t[0] === 'description')?.[1] || ''; } + async function organizeRepos() { + if (!profileOwnerPubkeyHex) return; + + ownedRepos = []; + maintainedRepos = []; + favoriteRepos = []; + + const userPubkey = profileOwnerPubkeyHex; // Store in local variable for type safety + if (!userPubkey) return; + + try { + // Separate owned repos from the initial list + ownedRepos = repos.filter(r => r.pubkey === userPubkey); + + // Load favorites (bookmarks kind 10003) + const bookmarkEvents = await nostrClient.fetchEvents([ + { + kinds: [KIND.BOOKMARKS], + authors: [userPubkey], + limit: 100 + } + ]); + + // Extract repo a-tags from bookmarks + const favoriteATags = new Set(); + for (const bookmark of bookmarkEvents) { + for (const tag of bookmark.tags) { + if (tag[0] === 'a' && tag[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)) { + favoriteATags.add(tag[1]); + } + } + } + + // Fetch repo announcements for bookmarked repos + // Parse a-tags to get author and d-tag, then fetch specific repos + if (favoriteATags.size > 0) { + const favoriteRepoPromises: Promise[] = []; + + for (const aTag of favoriteATags) { + // Parse a-tag format: "30617:pubkey:d-tag" + const parts = aTag.split(':'); + if (parts.length >= 3 && parts[0] === String(KIND.REPO_ANNOUNCEMENT)) { + const repoOwnerPubkey = parts[1]; + const repoId = parts[2]; + + // Skip if this is the user's own repo (already in ownedRepos) + if (repoOwnerPubkey.toLowerCase() === userPubkey.toLowerCase()) { + continue; + } + + // Fetch the specific repo announcement + favoriteRepoPromises.push( + nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkey], + '#d': [repoId], + limit: 1 + } + ]).then(events => events[0] || null) + ); + } + } + + const favoriteRepoResults = await Promise.all(favoriteRepoPromises); + favoriteRepos = favoriteRepoResults.filter((repo): repo is NostrEvent => repo !== null); + } + + // For maintained repos, we need to search through repos + // This is less efficient, so we'll search recent repos and check maintainers + // Limit to a reasonable number to avoid performance issues + const recentRepoEvents = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + limit: 200 // Search through recent repos + } + ]); + + // Filter for repos where user is a maintainer (but not owner) + for (const repo of recentRepoEvents) { + if (repo.pubkey.toLowerCase() === userPubkey.toLowerCase()) continue; // Skip owned repos + + const maintainersTag = repo.tags.find(t => t[0] === 'maintainers'); + if (maintainersTag) { + const isMaintainer = maintainersTag.slice(1).some(m => { + if (!m) return false; + try { + const decoded = nip19.decode(m); + if (decoded.type === 'npub') { + return (decoded.data as string).toLowerCase() === userPubkey.toLowerCase(); + } + } catch { + // Assume hex + } + return m.toLowerCase() === userPubkey.toLowerCase(); + }); + if (isMaintainer) { + maintainedRepos.push(repo); + } + } + } + + // Remove duplicates (a repo could be both maintained and favorited) + maintainedRepos = maintainedRepos.filter((repo, index, self) => + index === self.findIndex(r => r.id === repo.id) + ); + favoriteRepos = favoriteRepos.filter((repo, index, self) => + index === self.findIndex(r => r.id === repo.id) && + !maintainedRepos.find(m => m.id === repo.id) && + !ownedRepos.find(o => o.id === repo.id) + ); + + } catch (err) { + console.error('Failed to organize repos:', err); + // Fallback: just mark owned repos + ownedRepos = repos.filter(r => r.pubkey === profileOwnerPubkeyHex); + } + } + function getRepoId(event: NostrEvent): string { return event.tags.find(t => t[0] === 'd')?.[1] || ''; } @@ -271,6 +523,215 @@ return date.toLocaleDateString(); } + async function loadActivity() { + if (!profileOwnerPubkeyHex || loadingActivity || activityLoaded) return; + + const userPubkey = profileOwnerPubkeyHex; // Store in local variable for type safety + loadingActivity = true; + try { + // Step 1: Fetch all repo announcements where user is owner or maintainer + const repoAnnouncements = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [userPubkey], + limit: 100 + } + ]); + + // Step 2: Extract a-tags from repo announcements + const aTags = new Set(); + for (const announcement of repoAnnouncements) { + const dTag = announcement.tags.find(t => t[0] === 'd')?.[1]; + if (dTag) { + const aTag = `${KIND.REPO_ANNOUNCEMENT}:${announcement.pubkey}:${dTag}`; + aTags.add(aTag); + } + } + + // Step 3: Also check for repos where user is a maintainer (not just owner) + // We'll fetch announcements and check maintainer tags + const allAnnouncements = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + '#p': [userPubkey], // Events that mention the user + limit: 100 + } + ]); + + for (const announcement of allAnnouncements) { + // Check if user is in maintainers tag + const maintainersTag = announcement.tags.find(t => t[0] === 'maintainers'); + if (maintainersTag) { + const isMaintainer = maintainersTag.slice(1).some(m => { + // Handle both hex and npub formats + try { + const decoded = nip19.decode(m); + if (decoded.type === 'npub') { + return (decoded.data as string).toLowerCase() === userPubkey.toLowerCase(); + } + } catch { + // Assume hex + } + return m.toLowerCase() === userPubkey.toLowerCase(); + }); + + if (isMaintainer) { + const dTag = announcement.tags.find(t => t[0] === 'd')?.[1]; + if (dTag) { + const aTag = `${KIND.REPO_ANNOUNCEMENT}:${announcement.pubkey}:${dTag}`; + aTags.add(aTag); + } + } + } + } + + // Step 4: Fetch events that reference the user or their repos + const filters: any[] = []; + + // Events with user in p-tag + filters.push({ + '#p': [userPubkey], + limit: 200 + }); + + // Events with user in q-tag + filters.push({ + '#q': [userPubkey], + limit: 200 + }); + + // Events with repo a-tags + if (aTags.size > 0) { + filters.push({ + '#a': Array.from(aTags), + limit: 200 + }); + } + + const allActivityEvents = await nostrClient.fetchEvents(filters); + + // Step 5: Deduplicate, filter, and sort by created_at (newest first) + const eventMap = new Map(); + for (const event of allActivityEvents) { + // Use shared exclusion function to filter out user's own events and write-proof messages + if (shouldExcludeEvent(event, userPubkey)) { + continue; + } + + // Keep the newest version if duplicate + const existing = eventMap.get(event.id); + if (!existing || event.created_at > existing.created_at) { + eventMap.set(event.id, event); + } + } + + // Sort by created_at descending and limit to 200 + activityEvents = Array.from(eventMap.values()) + .sort((a, b) => b.created_at - a.created_at) + .slice(0, 200); + + } catch (err) { + console.error('Failed to load activity:', err); + error = 'Failed to load activity'; + } finally { + activityLoaded = true; // Mark as loaded to prevent infinite loop (even on error) + loadingActivity = false; + } + } + + function getEventContext(event: NostrEvent): string { + // Extract context from event content or tags + if (event.content && event.content.trim()) { + // Limit to first 200 characters + const content = event.content.trim(); + // Skip if it's just whitespace or very short + if (content.length > 3) { + if (content.length <= 200) { + return content; + } + return content.substring(0, 197) + '...'; + } + } + + // Try to get context from tags (in order of preference) + const nameTag = event.tags.find(t => t[0] === 'name')?.[1]; + if (nameTag && nameTag.trim()) { + return nameTag.trim(); + } + + const descriptionTag = event.tags.find(t => t[0] === 'description')?.[1]; + if (descriptionTag && descriptionTag.trim()) { + const desc = descriptionTag.trim(); + return desc.length > 200 ? desc.substring(0, 197) + '...' : desc; + } + + // Try summary tag + const summaryTag = event.tags.find(t => t[0] === 'summary')?.[1]; + if (summaryTag && summaryTag.trim()) { + return summaryTag.trim(); + } + + // Try title tag + const titleTag = event.tags.find(t => t[0] === 'title')?.[1]; + if (titleTag && titleTag.trim()) { + return titleTag.trim(); + } + + // Build context from kind and other tags + const kindNames: Record = { + [KIND.PULL_REQUEST]: 'Pull Request', + [KIND.ISSUE]: 'Issue', + [KIND.COMMENT]: 'Comment', + [KIND.PATCH]: 'Patch', + [KIND.REPO_ANNOUNCEMENT]: 'Repository Announcement', + [KIND.REPO_STATE]: 'Repository State', + [KIND.PUBLIC_MESSAGE]: 'Public Message', + }; + + const kindName = kindNames[event.kind] || `Event kind ${event.kind}`; + + // Try to add repo context if available + const aTag = event.tags.find(t => t[0] === 'a' && t[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)); + if (aTag && aTag[1]) { + const parts = aTag[1].split(':'); + if (parts.length >= 3) { + const repoId = parts[2]; + return `${kindName} - ${repoId}`; + } + } + + return kindName; + } + + function getEventLink(event: NostrEvent): string { + // Create a link to view the event using nevent or naddr + try { + // Check if it's a parameterized replaceable event (has 'a' tag) + const aTag = event.tags.find(t => t[0] === 'a'); + if (aTag && aTag[1]) { + // Use naddr for parameterized replaceable events + const naddr = nip19.naddrEncode({ + identifier: event.tags.find(t => t[0] === 'd')?.[1] || '', + pubkey: event.pubkey, + kind: event.kind + }); + return `https://aitherboard.imwald.eu/event/${naddr}`; + } else { + // Use nevent for regular events + const nevent = nip19.neventEncode({ + id: event.id, + author: event.pubkey, + kind: event.kind + }); + return `https://aitherboard.imwald.eu/event/${nevent}`; + } + } catch (err) { + console.error('Failed to encode event link:', err); + // Fallback to event ID + return `#${event.id.substring(0, 8)}`; + } + } + async function copyPaytoAddress(payto: string) { try { await navigator.clipboard.writeText(payto); @@ -290,6 +751,22 @@ } const isOwnProfile = $derived(viewerPubkeyHex === profileOwnerPubkeyHex); + + // Sort payment targets with lightning first + const sortedPaymentTargets = $derived.by(() => { + return [...paymentTargets].sort((a, b) => { + const aType = a.type.toLowerCase(); + const bType = b.type.toLowerCase(); + if (aType === 'lightning') return -1; + if (bType === 'lightning') return 1; + return aType.localeCompare(bType); + }); + }); + + // Display address without payto:// prefix + function getDisplayAddress(payto: string): string { + return payto.replace(/^payto:\/\//, ''); + }
@@ -336,17 +813,44 @@ {/if} + + {#if profileTags.length > 0} +
+

Profile Metadata

+
+ {#each profileTags as tag} +
+ {tag.name}: +
+ {#each tag.values as value, index} +
+ {#if tag.name === 'website'} + {value} + {:else if tag.name === 'nip05'} + {value} + {#if tag.verified && tag.verified[index]} + Verified + {/if} + {:else} + {value} + {/if} +
+ {/each} +
+
+ {/each} +
+
+ {/if} + {#if paymentTargets.length > 0}

Payment Methods

- {#each paymentTargets as target} + {#each sortedPaymentTargets as target}
-
- {target.type} -
- {target.payto} + {getDisplayAddress(target.payto)}
{#if target.type === 'lightning'} +
@@ -400,33 +911,107 @@

No repositories found

{:else} -
- {#each repos as event} - {@const repoId = getRepoId(event)} -
goto(`/repos/${npub}/${repoId}`)} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - goto(`/repos/${npub}/${repoId}`); - } - }} - > -

{getRepoName(event)}

- {#if getRepoDescription(event)} -

{getRepoDescription(event)}

- {/if} - + + {#if ownedRepos.length > 0} +
+

Repositories I Own

+
+ {#each ownedRepos as event} + {@const repoId = getRepoId(event)} +
goto(`/repos/${npub}/${repoId}`)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + goto(`/repos/${npub}/${repoId}`); + } + }} + > +

{getRepoName(event)}

+ {#if getRepoDescription(event)} +

{getRepoDescription(event)}

+ {/if} + +
+ {/each}
- {/each} -
+
+ {/if} + + + {#if maintainedRepos.length > 0} +
+

Repositories I Maintain

+
+ {#each maintainedRepos as event} + {@const repoId = getRepoId(event)} +
goto(`/repos/${npub}/${repoId}`)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + goto(`/repos/${npub}/${repoId}`); + } + }} + > +

{getRepoName(event)}

+ {#if getRepoDescription(event)} +

{getRepoDescription(event)}

+ {/if} + +
+ {/each} +
+
+ {/if} + + + {#if favoriteRepos.length > 0} +
+

Favorite Repositories

+
+ {#each favoriteRepos as event} + {@const repoId = getRepoId(event)} +
goto(`/repos/${npub}/${repoId}`)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + goto(`/repos/${npub}/${repoId}`); + } + }} + > +

{getRepoName(event)}

+ {#if getRepoDescription(event)} +

{getRepoDescription(event)}

+ {/if} + +
+ {/each} +
+
+ {/if} {/if}
{:else if activeTab === 'messages'} @@ -474,6 +1059,47 @@
{/if} + {:else if activeTab === 'activity'} +
+
+

Activity

+
+ + {#if loadingActivity} +
+
+

Loading activity...

+
+ {:else if activityEvents.length === 0} +
+

No activity found

+
+ {:else} +
+ {#each activityEvents as event} +
+
+

{getEventContext(event)}

+
+ +
+ {/each} +
+ {/if} +
{/if} {/if} @@ -673,8 +1299,88 @@ border-radius: 1rem; } + /* Profile Tags */ + .profile-tags-section { + margin: 2rem 0; + } + + .profile-tags-section h2 { + margin: 0 0 1.5rem 0; + font-size: 1.5rem; + color: var(--text-primary); + } + + .profile-tags-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + } + + .profile-tag-item { + padding: 0.75rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + display: flex; + gap: 0.5rem; + align-items: flex-start; + } + + .tag-name { + font-weight: 600; + color: var(--text-secondary); + flex-shrink: 0; + } + + .tag-values { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .tag-value-item { + color: var(--text-primary); + word-break: break-word; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .tag-value-item a { + color: var(--accent); + text-decoration: none; + } + + .tag-value-item a:hover { + text-decoration: underline; + } + + .nip05-value { + color: var(--text-primary); + } + + .verified-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + opacity: 0.8; + filter: brightness(0) saturate(100%) invert(1); /* Default white for dark themes */ + } + + /* Light theme: green check icon */ + :global([data-theme="light"]) .verified-icon { + filter: brightness(0) saturate(100%) invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%); + } + + /* Dark themes: green check icon */ + :global([data-theme="dark"]) .verified-icon, + :global([data-theme="black"]) .verified-icon { + filter: brightness(0) saturate(100%) invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%); + } + .payment-section h2 { - margin: 0 0 1rem 0; + margin: 0 0 1.5rem 0; font-size: 1.5rem; color: var(--text-primary); } @@ -687,7 +1393,8 @@ .payment-card { display: flex; - flex-direction: column; + flex-direction: row; + align-items: flex-start; gap: 0.75rem; padding: 1rem; background: var(--bg-secondary); @@ -696,33 +1403,21 @@ position: relative; } - .payment-header { - display: flex; - align-items: center; - justify-content: space-between; - } - - .payment-type { - font-weight: 600; - color: var(--text-primary); - text-transform: capitalize; - } - .payment-address { font-family: 'IBM Plex Mono', monospace; font-size: 0.875rem; color: var(--text-secondary); word-break: break-all; flex: 1; + min-width: 0; + padding-right: 0.5rem; } .payment-actions { - position: absolute; - top: 0.75rem; - right: 0.75rem; display: flex; gap: 0.5rem; align-items: center; + flex-shrink: 0; } .copy-button, @@ -834,6 +1529,21 @@ } /* Repositories */ + .repo-section-group { + margin-bottom: 3rem; + } + + .repo-section-group:last-child { + margin-bottom: 0; + } + + .repo-section-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 1rem 0; + } + .repo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); @@ -999,6 +1709,101 @@ line-height: 1.6; } + /* Activity */ + .activity-section { + padding: 0; + } + + .activity-header { + margin-bottom: 1.5rem; + } + + .activity-header h2 { + margin: 0; + font-size: 1.5rem; + color: var(--text-primary); + } + + .activity-list { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .activity-card { + padding: 1.5rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.75rem; + transition: all 0.2s ease; + } + + .activity-card:hover { + border-color: var(--accent); + } + + :global([data-theme="light"]) .activity-card { + background: #f5f5f5; + } + + :global([data-theme="dark"]) .activity-card { + background: rgba(0, 0, 0, 0.3); + } + + :global([data-theme="black"]) .activity-card { + background: #1a1a1a; + } + + .activity-context { + margin-bottom: 1rem; + } + + .activity-blurb { + color: var(--text-primary); + line-height: 1.6; + margin: 0; + word-wrap: break-word; + } + + .activity-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + } + + .activity-author { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; + } + + .activity-time { + font-size: 0.875rem; + color: var(--text-muted); + white-space: nowrap; + } + + .activity-link-button { + padding: 0.5rem 1rem; + background: var(--accent); + color: var(--accent-text, #ffffff); + border: none; + border-radius: 0.5rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + display: inline-block; + } + + .activity-link-button:hover { + opacity: 0.9; + } + /* Empty State */ .empty-state { text-align: center;