2 changed files with 455 additions and 0 deletions
@ -0,0 +1,451 @@
@@ -0,0 +1,451 @@
|
||||
<script lang="ts"> |
||||
import { onMount } from "svelte"; |
||||
import { Heading, P } from "flowbite-svelte"; |
||||
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
||||
import { userStore } from "$lib/stores/userStore"; |
||||
import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte"; |
||||
import { ndkInstance, activeInboxRelays } from "$lib/ndk"; |
||||
import { neventEncode } from "$lib/utils"; |
||||
import { getUserMetadata, NDKRelaySetFromNDK } from "$lib/utils/nostrUtils"; |
||||
import { goto } from "$app/navigation"; |
||||
import { get } from "svelte/store"; |
||||
import { nip19 } from "nostr-tools"; |
||||
import { communityRelays, localRelays } from "$lib/consts"; |
||||
|
||||
const { event } = $props<{ event: NDKEvent }>(); |
||||
|
||||
// Component state |
||||
let notifications = $state<NDKEvent[]>([]); |
||||
let loading = $state(false); |
||||
let error = $state<string | null>(null); |
||||
let isOwnProfile = $state(false); |
||||
let notificationMode = $state<"to-me" | "from-me">("to-me"); |
||||
let authorProfiles = $state<Map<string, { name?: string; displayName?: string; picture?: string }>>(new Map()); |
||||
|
||||
// Check if user is viewing their own profile |
||||
$effect(() => { |
||||
if ($userStore.signedIn && $userStore.pubkey && event.pubkey) { |
||||
isOwnProfile = $userStore.pubkey.toLowerCase() === event.pubkey.toLowerCase(); |
||||
} else { |
||||
isOwnProfile = false; |
||||
} |
||||
}); |
||||
|
||||
// Fetch notifications when viewing own profile or when mode changes |
||||
$effect(() => { |
||||
if (isOwnProfile && $userStore.pubkey && $userStore.signedIn) { |
||||
fetchNotifications(); |
||||
} else { |
||||
// Clear notifications when user logs out or is not viewing own profile |
||||
notifications = []; |
||||
authorProfiles.clear(); |
||||
} |
||||
}); |
||||
|
||||
async function fetchNotifications() { |
||||
if (!$userStore.pubkey || !isOwnProfile) return; |
||||
|
||||
loading = true; |
||||
error = null; |
||||
|
||||
try { |
||||
const ndk = get(ndkInstance); |
||||
if (!ndk) { |
||||
throw new Error("No NDK instance available"); |
||||
} |
||||
|
||||
// Collect all available relays for notification discovery |
||||
const userInboxRelays = $userStore.relays.inbox || []; |
||||
const userOutboxRelays = $userStore.relays.outbox || []; |
||||
const activeInboxRelayList = get(activeInboxRelays); |
||||
|
||||
// Combine user's relays, local relays, community relays, and active inbox relays |
||||
const allRelays = [ |
||||
...userInboxRelays, |
||||
...userOutboxRelays, |
||||
...localRelays, |
||||
...communityRelays, |
||||
...activeInboxRelayList |
||||
]; |
||||
|
||||
// Remove duplicates |
||||
const uniqueRelays = [...new Set(allRelays)]; |
||||
|
||||
if (uniqueRelays.length === 0) { |
||||
throw new Error("No relays available for notification discovery"); |
||||
} |
||||
|
||||
console.log("[Notifications] Using relays for discovery:", uniqueRelays); |
||||
|
||||
// Create filter based on notification mode |
||||
let filter; |
||||
if (notificationMode === "to-me") { |
||||
// Events that mention the user (notifications TO me) |
||||
filter = { |
||||
kinds: [1, 1111, 9802, 6, 16, 24], |
||||
"#p": [$userStore.pubkey], // Events that mention the user |
||||
limit: 100, |
||||
}; |
||||
} else { |
||||
// Events authored by the user (notifications FROM me) |
||||
filter = { |
||||
kinds: [1, 1111, 9802, 6, 16, 24], |
||||
authors: [$userStore.pubkey], // Events authored by the user |
||||
limit: 100, |
||||
}; |
||||
} |
||||
|
||||
console.log("[Notifications] Fetching notifications for user:", $userStore.pubkey); |
||||
console.log("[Notifications] Mode:", notificationMode); |
||||
console.log("[Notifications] Using filter:", filter); |
||||
|
||||
// Create a relay set from all available relays |
||||
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(uniqueRelays, ndk); |
||||
|
||||
const events = await ndk.fetchEvents(filter, undefined, relaySet); |
||||
|
||||
// Convert to array and filter out self-referential events |
||||
const eventArray = Array.from(events); |
||||
|
||||
// Filter out self-referential events |
||||
const filteredEvents = eventArray.filter(event => { |
||||
if (notificationMode === "to-me") { |
||||
// For "to-me" mode, exclude events where the user is the author |
||||
return event.pubkey !== $userStore.pubkey; |
||||
} else { |
||||
// For "from-me" mode, exclude events where the user is mentioned in p tags |
||||
const pTags = event.getMatchingTags("p"); |
||||
const isUserMentioned = pTags.some(tag => tag[1] === $userStore.pubkey); |
||||
return !isUserMentioned; |
||||
} |
||||
}); |
||||
|
||||
// Sort by creation time (newest first) |
||||
filteredEvents.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); |
||||
|
||||
// Take the newest 100 |
||||
notifications = filteredEvents.slice(0, 100); |
||||
|
||||
console.log("[Notifications] Found", notifications.length, "notifications"); |
||||
|
||||
// Fetch profiles for all notification authors |
||||
await fetchAuthorProfiles(notifications); |
||||
|
||||
} catch (err) { |
||||
console.error("[Notifications] Error fetching notifications:", err); |
||||
error = err instanceof Error ? err.message : "Failed to fetch notifications"; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
// Fetch author profiles with relay priority |
||||
async function fetchAuthorProfiles(events: NDKEvent[]) { |
||||
const uniquePubkeys = new Set<string>(); |
||||
events.forEach(event => { |
||||
if (event.pubkey) { |
||||
uniquePubkeys.add(event.pubkey); |
||||
} |
||||
}); |
||||
|
||||
console.log(`[Notifications] Fetching profiles for ${uniquePubkeys.size} unique authors`); |
||||
|
||||
// Fetch profiles in parallel with relay priority |
||||
const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => { |
||||
try { |
||||
const npub = toNpub(pubkey); |
||||
if (!npub) return; |
||||
|
||||
// First try cache |
||||
let profile = await getUserMetadata(npub, false); |
||||
if (profile && (profile.name || profile.displayName || profile.picture)) { |
||||
authorProfiles.set(pubkey, profile); |
||||
return; |
||||
} |
||||
|
||||
// If not in cache, try search relays first |
||||
const searchRelays = [ |
||||
"wss://profiles.nostr1.com", |
||||
"wss://aggr.nostr.land", |
||||
"wss://relay.noswhere.com", |
||||
"wss://nostr.wine", |
||||
"wss://relay.damus.io", |
||||
"wss://relay.nostr.band", |
||||
"wss://freelay.sovbit.host" |
||||
]; |
||||
|
||||
// Try search relays |
||||
for (const relay of searchRelays) { |
||||
try { |
||||
const ndk = get(ndkInstance); |
||||
if (!ndk) break; |
||||
|
||||
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk); |
||||
const profileEvent = await ndk.fetchEvent( |
||||
{ kinds: [0], authors: [pubkey] }, |
||||
undefined, |
||||
relaySet |
||||
); |
||||
|
||||
if (profileEvent) { |
||||
const profileData = JSON.parse(profileEvent.content); |
||||
const profile = { |
||||
name: profileData.name, |
||||
displayName: profileData.display_name || profileData.displayName, |
||||
picture: profileData.picture || profileData.image |
||||
}; |
||||
authorProfiles.set(pubkey, profile); |
||||
return; |
||||
} |
||||
} catch (error) { |
||||
console.warn(`[Notifications] Failed to fetch profile from ${relay}:`, error); |
||||
} |
||||
} |
||||
|
||||
// Finally try all available relays |
||||
const userInboxRelays = $userStore.relays.inbox || []; |
||||
const userOutboxRelays = $userStore.relays.outbox || []; |
||||
const activeInboxRelayList = get(activeInboxRelays); |
||||
|
||||
const allRelays = [ |
||||
...userInboxRelays, |
||||
...userOutboxRelays, |
||||
...localRelays, |
||||
...communityRelays, |
||||
...activeInboxRelayList |
||||
]; |
||||
|
||||
const uniqueRelays = [...new Set(allRelays)]; |
||||
if (uniqueRelays.length > 0) { |
||||
try { |
||||
const ndk = get(ndkInstance); |
||||
if (!ndk) return; |
||||
|
||||
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(uniqueRelays, ndk); |
||||
const profileEvent = await ndk.fetchEvent( |
||||
{ kinds: [0], authors: [pubkey] }, |
||||
undefined, |
||||
relaySet |
||||
); |
||||
|
||||
if (profileEvent) { |
||||
const profileData = JSON.parse(profileEvent.content); |
||||
const profile = { |
||||
name: profileData.name, |
||||
displayName: profileData.display_name || profileData.displayName, |
||||
picture: profileData.picture || profileData.image |
||||
}; |
||||
authorProfiles.set(pubkey, profile); |
||||
} |
||||
} catch (error) { |
||||
console.warn(`[Notifications] Failed to fetch profile from all relays:`, error); |
||||
} |
||||
} |
||||
|
||||
} catch (error) { |
||||
console.warn(`[Notifications] Failed to fetch profile for ${pubkey}:`, error); |
||||
} |
||||
}); |
||||
|
||||
await Promise.allSettled(profilePromises); |
||||
console.log(`[Notifications] Profile fetching complete`); |
||||
} |
||||
|
||||
function toNpub(pubkey: string): string | null { |
||||
if (!pubkey) return null; |
||||
try { |
||||
if (/^[a-f0-9]{64}$/i.test(pubkey)) { |
||||
return nip19.npubEncode(pubkey); |
||||
} |
||||
if (pubkey.startsWith("npub1")) return pubkey; |
||||
return null; |
||||
} catch { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
function getNotificationType(event: NDKEvent): string { |
||||
switch (event.kind) { |
||||
case 1: |
||||
return "Reply"; |
||||
case 1111: |
||||
return "Custom Reply"; |
||||
case 9802: |
||||
return "Highlight"; |
||||
case 6: |
||||
return "Repost"; |
||||
case 16: |
||||
return "Generic Repost"; |
||||
case 24: |
||||
return "Public Message"; |
||||
default: |
||||
return `Kind ${event.kind}`; |
||||
} |
||||
} |
||||
|
||||
function getNeventUrl(event: NDKEvent): string { |
||||
// Use the same relay set as for fetching notifications |
||||
const userInboxRelays = $userStore.relays.inbox || []; |
||||
const userOutboxRelays = $userStore.relays.outbox || []; |
||||
const activeInboxRelayList = get(activeInboxRelays); |
||||
|
||||
const allRelays = [ |
||||
...userInboxRelays, |
||||
...userOutboxRelays, |
||||
...localRelays, |
||||
...communityRelays, |
||||
...activeInboxRelayList |
||||
]; |
||||
|
||||
const uniqueRelays = [...new Set(allRelays)]; |
||||
return neventEncode(event, uniqueRelays); |
||||
} |
||||
|
||||
function navigateToEvent(nevent: string) { |
||||
goto(`/events?id=${nevent}`); |
||||
} |
||||
|
||||
function formatDate(timestamp: number): string { |
||||
const date = new Date(timestamp * 1000); |
||||
const now = new Date(); |
||||
const diffMs = now.getTime() - date.getTime(); |
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); |
||||
|
||||
if (diffDays === 0) { |
||||
return "Today"; |
||||
} else if (diffDays === 1) { |
||||
return "Yesterday"; |
||||
} else if (diffDays < 7) { |
||||
return `${diffDays} days ago`; |
||||
} else { |
||||
return date.toLocaleDateString(); |
||||
} |
||||
} |
||||
|
||||
function truncateContent(content: string, maxLength: number = 300): string { |
||||
if (content.length <= maxLength) return content; |
||||
return content.slice(0, maxLength) + "..."; |
||||
} |
||||
</script> |
||||
|
||||
{#if isOwnProfile && $userStore.signedIn} |
||||
<div class="mb-6"> |
||||
<div class="flex items-center justify-between mb-4"> |
||||
<Heading tag="h3" class="h-leather">Notifications</Heading> |
||||
|
||||
<!-- Toggle between "to me" and "from me" --> |
||||
<div class="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1"> |
||||
<button |
||||
class="px-3 py-1 text-sm font-medium rounded-md transition-colors {notificationMode === 'to-me' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'}" |
||||
onclick={() => { |
||||
notificationMode = "to-me"; |
||||
}} |
||||
> |
||||
To Me |
||||
</button> |
||||
<button |
||||
class="px-3 py-1 text-sm font-medium rounded-md transition-colors {notificationMode === 'from-me' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'}" |
||||
onclick={() => { |
||||
notificationMode = "from-me"; |
||||
}} |
||||
> |
||||
From Me |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
{#if loading} |
||||
<div class="flex items-center justify-center py-8"> |
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div> |
||||
<span class="ml-2 text-gray-600 dark:text-gray-400">Loading notifications...</span> |
||||
</div> |
||||
{:else if error} |
||||
<div class="p-4 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 rounded-lg"> |
||||
<P>Error loading notifications: {error}</P> |
||||
</div> |
||||
{:else if notifications.length === 0} |
||||
<div class="p-4 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-lg"> |
||||
<P>No notifications {notificationMode === "to-me" ? "received" : "sent"} found.</P> |
||||
</div> |
||||
{:else} |
||||
<div class="max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg"> |
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700"> |
||||
{#each notifications.slice(0, 10) as notification} |
||||
{@const authorProfile = authorProfiles.get(notification.pubkey)} |
||||
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"> |
||||
<div class="flex items-start gap-3"> |
||||
<!-- Author Profile Picture --> |
||||
<div class="flex-shrink-0"> |
||||
{#if authorProfile?.picture} |
||||
<img |
||||
src={authorProfile.picture} |
||||
alt="Author avatar" |
||||
class="w-10 h-10 rounded-full object-cover border border-gray-200 dark:border-gray-600" |
||||
onerror={(e) => { |
||||
(e.target as HTMLImageElement).style.display = 'none'; |
||||
}} |
||||
/> |
||||
{:else} |
||||
<div class="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600"> |
||||
<span class="text-sm font-medium text-gray-600 dark:text-gray-300"> |
||||
{(authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 1)).toUpperCase()} |
||||
</span> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<!-- Notification Content --> |
||||
<div class="flex-1 min-w-0"> |
||||
<div class="flex items-center gap-2 mb-2"> |
||||
<span class="text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-100 dark:bg-primary-900 px-2 py-1 rounded"> |
||||
{getNotificationType(notification)} |
||||
</span> |
||||
<span class="text-xs text-gray-500 dark:text-gray-400"> |
||||
{notification.created_at ? formatDate(notification.created_at) : "Unknown date"} |
||||
</span> |
||||
</div> |
||||
|
||||
<!-- Author Name --> |
||||
<div class="mb-2"> |
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"> |
||||
{authorProfile?.displayName || authorProfile?.name || `${notification.pubkey.slice(0, 8)}...${notification.pubkey.slice(-4)}`} |
||||
</span> |
||||
{#if authorProfile?.name && authorProfile?.displayName && authorProfile.name !== authorProfile.displayName} |
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1"> |
||||
(@{authorProfile.name}) |
||||
</span> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if notification.content} |
||||
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed"> |
||||
{truncateContent(notification.content)} |
||||
</div> |
||||
{/if} |
||||
|
||||
<div class="flex items-center gap-2"> |
||||
<button |
||||
class="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-200 underline font-medium" |
||||
onclick={() => navigateToEvent(getNeventUrl(notification))} |
||||
> |
||||
View Event |
||||
</button> |
||||
<span class="text-xs text-gray-500 dark:text-gray-400 font-mono"> |
||||
{getNeventUrl(notification).slice(0, 16)}... |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
|
||||
{#if notifications.length > 10} |
||||
<div class="p-4 text-center text-sm text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700"> |
||||
Showing 10 of {notifications.length} notifications {notificationMode === "to-me" ? "received" : "sent"}. Scroll to see more. |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
Loading…
Reference in new issue