Browse Source

Redid the formatting.

master
silberengel 7 months ago
parent
commit
8479a8c7dc
  1. 397
      src/lib/components/Notifications.svelte
  2. 240
      src/lib/utils/kind24_utils.ts
  3. 225
      src/lib/utils/notification_utils.ts

397
src/lib/components/Notifications.svelte

@ -5,8 +5,6 @@
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte"; import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte";
import { ndkInstance, activeInboxRelays } from "$lib/ndk"; import { ndkInstance, activeInboxRelays } from "$lib/ndk";
import { neventEncode } from "$lib/utils";
import { getUserMetadata, NDKRelaySetFromNDK } from "$lib/utils/nostrUtils";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
@ -19,7 +17,19 @@
import { searchProfiles } from "$lib/utils/search_utility"; import { searchProfiles } from "$lib/utils/search_utility";
import type { NostrProfile } from "$lib/utils/search_types"; import type { NostrProfile } from "$lib/utils/search_types";
import { PlusOutline, ReplyOutline } from "flowbite-svelte-icons"; import { PlusOutline, ReplyOutline } from "flowbite-svelte-icons";
import {
truncateContent,
truncateRenderedContent,
parseContent,
renderQuotedContent,
getNotificationType,
fetchAuthorProfiles
} from "$lib/utils/notification_utils";
import { buildCompleteRelaySet } from "$lib/utils/relay_management";
import { formatDate, neventEncode } from "$lib/utils";
import { toNpub, getUserMetadata, NDKRelaySetFromNDK } from "$lib/utils/nostrUtils";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
@ -79,148 +89,9 @@
}); });
// AI-NOTE: Utility functions extracted to reduce code duplication // AI-NOTE: Utility functions extracted to reduce code duplication
function getAvailableRelays(): string[] {
const userInboxRelays = $userStore.relays.inbox || [];
const userOutboxRelays = $userStore.relays.outbox || [];
const activeInboxRelayList = get(activeInboxRelays);
const allRelays = [
...userInboxRelays,
...userOutboxRelays,
...localRelays,
...communityRelays,
...activeInboxRelayList
];
return [...new Set(allRelays)];
}
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 getNeventUrl(event: NDKEvent): string { function getNeventUrl(event: NDKEvent): string {
const relays = getAvailableRelays(); // Use empty relay list for nevent encoding - relays will be discovered by the events page
return neventEncode(event, relays); return neventEncode(event, []);
}
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";
if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
}
function truncateContent(content: string, maxLength: number = 300): string {
if (content.length <= maxLength) return content;
return content.slice(0, maxLength) + "...";
}
function truncateRenderedContent(renderedHtml: string, maxLength: number = 300): string {
// If the rendered HTML is short enough, return as-is
if (renderedHtml.length <= maxLength) return renderedHtml;
// Check if there are any gray quote boxes (jump-to-message divs)
const hasQuoteBoxes = renderedHtml.includes('jump-to-message');
if (hasQuoteBoxes) {
// Split content into quote boxes and regular text
const quoteBoxPattern = /<div class="block w-fit my-2 px-3 py-2 bg-gray-200[^>]*onclick="window\.dispatchEvent\(new CustomEvent\('jump-to-message'[^>]*>[^<]*<\/div>/g;
const quoteBoxes = renderedHtml.match(quoteBoxPattern) || [];
// Remove quote boxes temporarily to measure text length
let textOnly = renderedHtml.replace(quoteBoxPattern, '|||QUOTEBOX|||');
// If text without quote boxes is still too long, truncate it
if (textOnly.length > maxLength) {
const availableLength = maxLength - (quoteBoxes.join('').length);
if (availableLength > 50) { // Leave some reasonable space for text
textOnly = textOnly.slice(0, availableLength) + "...";
} else {
// If quote boxes take up too much space, just show them with minimal text
textOnly = textOnly.slice(0, 50) + "...";
}
}
// Restore quote boxes
let result = textOnly;
quoteBoxes.forEach(box => {
result = result.replace('|||QUOTEBOX|||', box);
});
return result;
} else {
// No quote boxes, simple truncation with HTML awareness
if (renderedHtml.includes('<')) {
// Has HTML tags, do a simple truncation but try to avoid breaking tags
const truncated = renderedHtml.slice(0, maxLength);
const lastTagStart = truncated.lastIndexOf('<');
const lastTagEnd = truncated.lastIndexOf('>');
if (lastTagStart > lastTagEnd) {
// We're in the middle of a tag, truncate before it
return renderedHtml.slice(0, lastTagStart) + "...";
}
return truncated + "...";
} else {
// Plain text, simple truncation
return renderedHtml.slice(0, maxLength) + "...";
}
}
}
async function parseContent(content: string): Promise<string> {
if (!content) return "";
let parsedContent = await parseBasicmarkup(content);
return parsedContent;
}
async function renderQuotedContent(message: NDKEvent): Promise<string> {
const qTags = message.getMatchingTags("q");
if (qTags.length === 0) return "";
const qTag = qTags[0];
const eventId = qTag[1];
if (eventId) {
// Find the quoted message in our public messages
const quotedMessage = publicMessages.find(msg => msg.id === eventId);
if (quotedMessage) {
const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content";
const parsedContent = await parseBasicmarkup(quotedContent);
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick="window.dispatchEvent(new CustomEvent('jump-to-message', { detail: '${eventId}' }))">${parsedContent}</div>`;
}
}
return "";
}
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 navigateToEvent(nevent: string) { function navigateToEvent(nevent: string) {
@ -605,88 +476,6 @@
} }
} }
// AI-NOTE: Simplified profile fetching with better error handling
async function fetchAuthorProfiles(events: NDKEvent[]) {
const uniquePubkeys = new Set<string>();
events.forEach(event => {
if (event.pubkey) uniquePubkeys.add(event.pubkey);
});
const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => {
try {
const npub = toNpub(pubkey);
if (!npub) return;
// Try cache first
let profile = await getUserMetadata(npub, false);
if (profile && (profile.name || profile.displayName || profile.picture)) {
authorProfiles.set(pubkey, profile);
return;
}
// 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);
authorProfiles.set(pubkey, {
name: profileData.name,
displayName: profileData.display_name || profileData.displayName,
picture: profileData.picture || profileData.image
});
return;
}
} catch (error) {
console.warn(`[Notifications] Failed to fetch profile from ${relay}:`, error);
}
}
// Try all available relays as fallback
const relays = getAvailableRelays();
if (relays.length > 0) {
try {
const ndk = get(ndkInstance);
if (!ndk) return;
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [pubkey] },
undefined,
relaySet
);
if (profileEvent) {
const profileData = JSON.parse(profileEvent.content);
authorProfiles.set(pubkey, {
name: profileData.name,
displayName: profileData.display_name || profileData.displayName,
picture: profileData.picture || profileData.image
});
}
} 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);
}
// AI-NOTE: Simplified notification fetching // AI-NOTE: Simplified notification fetching
async function fetchNotifications() { async function fetchNotifications() {
if (!$userStore.pubkey || !isOwnProfile) return; if (!$userStore.pubkey || !isOwnProfile) return;
@ -697,8 +486,11 @@
try { try {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) throw new Error("No NDK instance available"); if (!ndk) throw new Error("No NDK instance available");
const relays = getAvailableRelays(); const userStoreValue = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const relays = [...relaySet.inboxRelays, ...relaySet.outboxRelays];
if (relays.length === 0) throw new Error("No relays available"); if (relays.length === 0) throw new Error("No relays available");
const filter = { const filter = {
@ -710,8 +502,8 @@
limit: 100, limit: 100,
}; };
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk); const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk);
const events = await ndk.fetchEvents(filter, undefined, relaySet); const events = await ndk.fetchEvents(filter, undefined, ndkRelaySet);
const eventArray = Array.from(events); const eventArray = Array.from(events);
// Filter out self-referential events // Filter out self-referential events
@ -729,7 +521,7 @@
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) .sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
.slice(0, 100); .slice(0, 100);
await fetchAuthorProfiles(notifications); authorProfiles = await fetchAuthorProfiles(notifications);
} catch (err) { } catch (err) {
console.error("[Notifications] Error fetching notifications:", err); console.error("[Notifications] Error fetching notifications:", err);
error = err instanceof Error ? err.message : "Failed to fetch notifications"; error = err instanceof Error ? err.message : "Failed to fetch notifications";
@ -749,15 +541,18 @@
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) throw new Error("No NDK instance available"); if (!ndk) throw new Error("No NDK instance available");
const relays = getAvailableRelays(); const userStoreValue = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const relays = [...relaySet.inboxRelays, ...relaySet.outboxRelays];
if (relays.length === 0) throw new Error("No relays available"); if (relays.length === 0) throw new Error("No relays available");
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk); const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk);
// Fetch only kind 24 messages // Fetch only kind 24 messages
const [messagesEvents, userMessagesEvents] = await Promise.all([ const [messagesEvents, userMessagesEvents] = await Promise.all([
ndk.fetchEvents({ kinds: [24 as any], "#p": [$userStore.pubkey], limit: 200 }, undefined, relaySet), ndk.fetchEvents({ kinds: [24 as any], "#p": [$userStore.pubkey], limit: 200 }, undefined, ndkRelaySet),
ndk.fetchEvents({ kinds: [24 as any], authors: [$userStore.pubkey], limit: 200 }, undefined, relaySet) ndk.fetchEvents({ kinds: [24 as any], authors: [$userStore.pubkey], limit: 200 }, undefined, ndkRelaySet)
]); ]);
const allMessages = [ const allMessages = [
@ -774,7 +569,7 @@
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) .sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
.slice(0, 200); .slice(0, 200);
await fetchAuthorProfiles(publicMessages); authorProfiles = await fetchAuthorProfiles(publicMessages);
} catch (err) { } catch (err) {
console.error("[PublicMessages] Error fetching public messages:", err); console.error("[PublicMessages] Error fetching public messages:", err);
error = err instanceof Error ? err.message : "Failed to fetch public messages"; error = err instanceof Error ? err.message : "Failed to fetch public messages";
@ -865,16 +660,32 @@
// If no relays found from NIP-65, use fallback relays // If no relays found from NIP-65, use fallback relays
if (uniqueRelays.length === 0) { if (uniqueRelays.length === 0) {
console.log("[Relay Effect] No NIP-65 relays found, using fallback"); console.log("[Relay Effect] No NIP-65 relays found, using fallback");
const fallbackRelays = getAvailableRelays(); const ndk = get(ndkInstance);
newMessageRelays = fallbackRelays.slice(0, 5); // Limit to first 5 for performance if (ndk) {
const userStoreValue = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const fallbackRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays];
newMessageRelays = fallbackRelays.slice(0, 5); // Limit to first 5 for performance
} else {
newMessageRelays = [];
}
} else { } else {
newMessageRelays = uniqueRelays; newMessageRelays = uniqueRelays;
} }
} catch (error) { } catch (error) {
console.error("[Relay Effect] Error getting relay set:", error); console.error("[Relay Effect] Error getting relay set:", error);
console.log("[Relay Effect] Using fallback relays due to error"); console.log("[Relay Effect] Using fallback relays due to error");
const fallbackRelays = getAvailableRelays(); const ndk = get(ndkInstance);
newMessageRelays = fallbackRelays.slice(0, 5); if (ndk) {
const userStoreValue = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const fallbackRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays];
newMessageRelays = fallbackRelays.slice(0, 5);
} else {
newMessageRelays = [];
}
} }
} }
</script> </script>
@ -933,7 +744,7 @@
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg"> <div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-blue-700 dark:text-blue-300"> <span class="text-sm text-blue-700 dark:text-blue-300">
Filtered by user: {authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name || `${filteredByUser.slice(0, 8)}...${filteredByUser.slice(-4)}`} Filtered by user: {@render userBadge(filteredByUser, authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name)}
</span> </span>
<button <button
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline font-medium" class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline font-medium"
@ -950,22 +761,27 @@
{@const isFromUser = message.pubkey === $userStore.pubkey} {@const isFromUser = message.pubkey === $userStore.pubkey}
<div class="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md transition-all" data-event-id="{message.id}"> <div class="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md transition-all" data-event-id="{message.id}">
<div class="flex items-start gap-3 {isFromUser ? 'flex-row-reverse' : ''}"> <div class="flex items-start gap-3 {isFromUser ? 'flex-row-reverse' : ''}">
<!-- Author Profile Picture --> <!-- Author Profile Picture and Name -->
<div class="flex-shrink-0 relative"> <div class="flex-shrink-0 relative">
{#if authorProfile?.picture} <div class="flex items-center gap-2 {isFromUser ? 'flex-row-reverse' : ''}">
<img {#if authorProfile?.picture}
src={authorProfile.picture} <img
alt="Author avatar" src={authorProfile.picture}
class="w-10 h-10 rounded-full object-cover border border-gray-200 dark:border-gray-600" alt="Author avatar"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'} 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"> {:else}
<span class="text-sm font-medium text-gray-600 dark:text-gray-300"> <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">
{(authorProfile?.displayName || authorProfile?.name || message.pubkey.slice(0, 1)).toUpperCase()} <span class="text-sm font-medium text-gray-600 dark:text-gray-300">
</span> {(authorProfile?.displayName || authorProfile?.name || message.pubkey.slice(0, 1)).toUpperCase()}
</div> </span>
{/if} </div>
{/if}
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{@render userBadge(message.pubkey, authorProfile?.displayName || authorProfile?.name)}
</span>
</div>
<!-- Filter button for non-user messages --> <!-- Filter button for non-user messages -->
{#if !isFromUser} {#if !isFromUser}
@ -1016,21 +832,11 @@
</button> </button>
</div> </div>
<!-- Author Name -->
<div class="mb-2 {isFromUser ? 'text-right' : ''}">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{authorProfile?.displayName || authorProfile?.name || `${message.pubkey.slice(0, 8)}...${message.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 message.getMatchingTags("q").length > 0} {#if message.getMatchingTags("q").length > 0}
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed"> <div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
{#await renderQuotedContent(message) then quotedHtml} {#await renderQuotedContent(message, publicMessages) then quotedHtml}
{@html quotedHtml} {@html quotedHtml}
{:catch} {:catch}
<!-- Fallback if quoted content fails to render --> <!-- Fallback if quoted content fails to render -->
@ -1073,22 +879,27 @@
{@const authorProfile = authorProfiles.get(notification.pubkey)} {@const authorProfile = authorProfiles.get(notification.pubkey)}
<div class="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md transition-all"> <div class="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md transition-all">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<!-- Author Profile Picture --> <!-- Author Profile Picture and Name -->
<div class="flex-shrink-0"> <div class="flex-shrink-0">
{#if authorProfile?.picture} <div class="flex items-center gap-2">
<img {#if authorProfile?.picture}
src={authorProfile.picture} <img
alt="Author avatar" src={authorProfile.picture}
class="w-10 h-10 rounded-full object-cover border border-gray-200 dark:border-gray-600" alt="Author avatar"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'} 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"> {:else}
<span class="text-sm font-medium text-gray-600 dark:text-gray-300"> <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">
{(authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 1)).toUpperCase()} <span class="text-sm font-medium text-gray-600 dark:text-gray-300">
</span> {(authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 1)).toUpperCase()}
</div> </span>
{/if} </div>
{/if}
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{@render userBadge(notification.pubkey, authorProfile?.displayName || authorProfile?.name)}
</span>
</div>
</div> </div>
<!-- Notification Content --> <!-- Notification Content -->
@ -1109,21 +920,15 @@
</button> </button>
</div> </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} {#if notification.content}
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed"> <div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
{truncateContent(notification.content)} {#await parseContent(notification.content) then parsedContent}
{@html parsedContent}
{:catch}
{@html truncateContent(notification.content)}
{/await}
</div> </div>
{/if} {/if}
@ -1194,7 +999,7 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each selectedRecipients as recipient} {#each selectedRecipients as recipient}
<span class="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm"> <span class="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm">
{recipient.displayName || recipient.name || `${recipient.pubkey?.slice(0, 8)}...`} {@render userBadge(recipient.pubkey!, recipient.displayName || recipient.name)}
<button <button
onclick={() => { onclick={() => {
selectedRecipients = selectedRecipients.filter(r => r.pubkey !== recipient.pubkey); selectedRecipients = selectedRecipients.filter(r => r.pubkey !== recipient.pubkey);
@ -1320,7 +1125,7 @@
{/if} {/if}
<div class="flex flex-col text-left min-w-0 flex-1"> <div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate"> <span class="font-semibold truncate">
{profile.displayName || profile.name || recipientSearch} {@render userBadge(profile.pubkey!, profile.displayName || profile.name)}
</span> </span>
{#if profile.nip05} {#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1"> <span class="text-xs text-gray-500 flex items-center gap-1">

240
src/lib/utils/kind24_utils.ts

@ -6,92 +6,61 @@ import type NDK from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { createSignedEvent } from "./nostrEventService.ts"; import { createSignedEvent } from "./nostrEventService.ts";
import { anonymousRelays } from "../consts"; import { anonymousRelays } from "../consts";
import { buildCompleteRelaySet } from "./relay_management";
// AI-NOTE: Using existing relay utilities from relay_management.ts instead of duplicating functionality
/** /**
* Fetches user's outbox relays from NIP-65 relay list * Gets optimal relay set for kind 24 messages between two users
* @param ndk NDK instance * @param senderPubkey The sender's pubkey
* @param user User to fetch outbox relays for * @param recipientPubkey The recipient's pubkey
* @returns Promise that resolves to array of outbox relay URLs * @returns Promise resolving to relay URLs prioritized by commonality
*/ */
async function getUseroutboxRelays(ndk: NDK, user: NDKUser): Promise<string[]> { export async function getKind24RelaySet(
try { senderPubkey: string,
recipientPubkey: string
const relayList = await ndk.fetchEvent( ): Promise<string[]> {
{ const ndk = get(ndkInstance);
kinds: [10002], if (!ndk) {
authors: [user.pubkey], throw new Error("NDK not available");
}
);
if (!relayList) {
return [];
}
const outboxRelays: string[] = [];
relayList.tags.forEach((tag) => {
if (tag[0] === 'r' && tag[1]) {
// NIP-65: r tags with optional inbox/outbox markers
const marker = tag[2];
if (!marker || marker === 'outbox' || marker === 'both') {
// If no marker, marker is 'outbox', or marker is 'both', it's an outbox relay
outboxRelays.push(tag[1]);
}
// Note: inbox-only relays are NOT included in outbox relays
}
});
return outboxRelays;
} catch (error) {
return [];
} }
}
/** const senderPrefix = senderPubkey.slice(0, 8);
* Fetches user's inbox relays from NIP-65 relay list const recipientPrefix = recipientPubkey.slice(0, 8);
* @param ndk NDK instance
* @param user User to fetch inbox relays for console.log(`[getKind24RelaySet] Getting relays for ${senderPrefix} -> ${recipientPrefix}`);
* @returns Promise that resolves to array of inbox relay URLs
*/
async function getUserinboxRelays(ndk: NDK, user: NDKUser): Promise<string[]> {
try {
console.log(`[getUserinboxRelays] Fetching kind 10002 for user: ${user.pubkey.slice(0, 8)}`);
const relayList = await ndk.fetchEvent( try {
{ // Fetch both users' complete relay sets using existing utilities
kinds: [10002], const [senderRelaySet, recipientRelaySet] = await Promise.all([
authors: [user.pubkey], buildCompleteRelaySet(ndk, ndk.getUser({ pubkey: senderPubkey })),
} buildCompleteRelaySet(ndk, ndk.getUser({ pubkey: recipientPubkey }))
]);
// Use sender's outbox relays and recipient's inbox relays
const senderOutboxRelays = senderRelaySet.outboxRelays;
const recipientInboxRelays = recipientRelaySet.inboxRelays;
// Prioritize common relays for better privacy
const commonRelays = senderOutboxRelays.filter(relay =>
recipientInboxRelays.includes(relay)
);
const senderOnlyRelays = senderOutboxRelays.filter(relay =>
!recipientInboxRelays.includes(relay)
);
const recipientOnlyRelays = recipientInboxRelays.filter(relay =>
!senderOutboxRelays.includes(relay)
); );
if (!relayList) { // Prioritize: common relays first, then sender outbox, then recipient inbox
console.log(`[getUserinboxRelays] No kind 10002 relay list found for user: ${user.pubkey.slice(0, 8)}`); const finalRelays = [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays];
return [];
} console.log(`[getKind24RelaySet] ${senderPrefix}->${recipientPrefix} - Common: ${commonRelays.length}, Sender-only: ${senderOnlyRelays.length}, Recipient-only: ${recipientOnlyRelays.length}, Total: ${finalRelays.length}`);
console.log(`[getUserinboxRelays] Found relay list for user: ${user.pubkey.slice(0, 8)}, tags:`, relayList.tags); return finalRelays;
const inboxRelays: string[] = [];
relayList.tags.forEach((tag) => {
if (tag[0] === 'r' && tag[1]) {
// NIP-65: r tags with optional inbox/outbox markers
const marker = tag[2];
console.log(`[getUserinboxRelays] Processing relay tag:`, tag, `marker: ${marker}`);
if (!marker || marker === 'inbox' || marker === 'both') {
// If no marker, marker is 'inbox', or marker is 'both', it's an inbox relay
inboxRelays.push(tag[1]);
console.log(`[getUserinboxRelays] Added inbox relay: ${tag[1]} (marker: ${marker || 'none'})`);
}
// Note: outbox-only relays are NOT included in inbox relays
}
});
console.log(`[getUserinboxRelays] Final inbox relays for user ${user.pubkey.slice(0, 8)}:`, inboxRelays);
return inboxRelays;
} catch (error) { } catch (error) {
console.error(`[getUserinboxRelays] Error fetching inbox relays for user ${user.pubkey.slice(0, 8)}:`, error); console.error(`[getKind24RelaySet] Error getting relay set for ${senderPrefix}->${recipientPrefix}:`, error);
return []; throw error;
} }
} }
@ -117,76 +86,45 @@ export async function createKind24Reply(
} }
try { try {
// Get sender's outbox relays (NIP-65) // Get optimal relay set for this sender-recipient pair
const senderoutboxRelays = await getUseroutboxRelays(ndk, ndk.activeUser); const targetRelays = await getKind24RelaySet(ndk.activeUser.pubkey, recipientPubkey);
// Get recipient's inbox relays (NIP-65)
const recipientUser = ndk.getUser({ pubkey: recipientPubkey });
let recipientinboxRelays = await getUserinboxRelays(ndk, recipientUser);
// Fallback: if no inbox relays found, use recipient's outbox relays
if (recipientinboxRelays.length === 0) {
console.log(`[createKind24Reply] No inbox relays found for recipient, falling back to outbox relays`);
recipientinboxRelays = await getUseroutboxRelays(ndk, recipientUser);
}
// According to NIP-A4: Messages MUST be sent to the NIP-65 inbox relays of each receiver
// and the outbox relay of the sender
const targetRelays = [...new Set([...senderoutboxRelays, ...recipientinboxRelays])];
// Prioritize common relays between sender and recipient for better privacy
const commonRelays = senderoutboxRelays.filter(relay =>
recipientinboxRelays.includes(relay)
);
const senderOnlyRelays = senderoutboxRelays.filter(relay =>
!recipientinboxRelays.includes(relay)
);
const recipientOnlyRelays = recipientinboxRelays.filter(relay =>
!senderoutboxRelays.includes(relay)
);
// Prioritize: common relays first, then sender outbox, then recipient inbox if (targetRelays.length === 0) {
const prioritizedRelays = [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays];
if (prioritizedRelays.length === 0) {
return { success: false, error: "No relays available for publishing" }; return { success: false, error: "No relays available for publishing" };
} }
// Use the content as-is, quoted content is handled via q tag
const finalContent = content;
// Build tags for the kind 24 event // Build tags for the kind 24 event
const tags: string[][] = [ const tags: string[][] = [
["p", recipientPubkey, prioritizedRelays[0]] // Use first relay as primary ["p", recipientPubkey, targetRelays[0]] // Use first relay as primary
]; ];
// Add q tag if replying to an original event // Add q tag if replying to an original event
if (originalEvent) { if (originalEvent) {
tags.push(["q", originalEvent.id, prioritizedRelays[0] || anonymousRelays[0]]); tags.push(["q", originalEvent.id, targetRelays[0] || anonymousRelays[0]]);
} }
// Create and sign the event using the unified function (includes expiration tag) // Create and sign the event
const { event: signedEventData } = await createSignedEvent( const { event: signedEventData } = await createSignedEvent(
finalContent, content,
ndk.activeUser.pubkey, ndk.activeUser.pubkey,
24, 24,
tags tags
); );
// Create NDKEvent from the signed event data // Create NDKEvent and publish
const event = new NDKEvent(ndk, signedEventData); const event = new NDKEvent(ndk, signedEventData);
const relaySet = NDKRelaySet.fromRelayUrls(targetRelays, ndk);
// Publish to relays
const relaySet = NDKRelaySet.fromRelayUrls(prioritizedRelays, ndk);
const publishedToRelays = await event.publish(relaySet); const publishedToRelays = await event.publish(relaySet);
if (publishedToRelays.size > 0) { if (publishedToRelays.size > 0) {
return { success: true, eventId: event.id, relays: prioritizedRelays }; console.log(`[createKind24Reply] Successfully published to ${publishedToRelays.size} relays`);
return { success: true, eventId: event.id, relays: targetRelays };
} else { } else {
return { success: false, error: "Failed to publish to any relays", relays: prioritizedRelays }; console.warn(`[createKind24Reply] Failed to publish to any relays`);
return { success: false, error: "Failed to publish to any relays", relays: targetRelays };
} }
} catch (error) { } catch (error) {
console.error("[kind24_utils] Error creating kind 24 reply:", error); console.error("[createKind24Reply] Error creating kind 24 reply:", error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : "Unknown error" error: error instanceof Error ? error.message : "Unknown error"
@ -194,62 +132,4 @@ export async function createKind24Reply(
} }
} }
/**
* Gets optimal relay set for kind 24 messages between two users
* @param senderPubkey The sender's pubkey
* @param recipientPubkey The recipient's pubkey
* @returns Promise resolving to relay URLs prioritized by commonality
*/
export async function getKind24RelaySet(
senderPubkey: string,
recipientPubkey: string
): Promise<string[]> {
const ndk = get(ndkInstance);
if (!ndk) {
throw new Error("NDK not available");
}
console.log(`[getKind24RelaySet] Getting relays for sender: ${senderPubkey.slice(0, 8)} -> recipient: ${recipientPubkey.slice(0, 8)}`);
// Get sender's outbox relays (NIP-65)
const senderUser = ndk.getUser({ pubkey: senderPubkey });
const senderoutboxRelays = await getUseroutboxRelays(ndk, senderUser);
console.log(`[getKind24RelaySet] Sender outbox relays:`, senderoutboxRelays);
// Get recipient's inbox relays (NIP-65)
const recipientUser = ndk.getUser({ pubkey: recipientPubkey });
let recipientinboxRelays = await getUserinboxRelays(ndk, recipientUser);
console.log(`[getKind24RelaySet] Recipient inbox relays:`, recipientinboxRelays);
// Fallback: if no inbox relays found, use recipient's outbox relays
if (recipientinboxRelays.length === 0) {
console.log(`[getKind24RelaySet] No inbox relays found for recipient, falling back to outbox relays`);
recipientinboxRelays = await getUseroutboxRelays(ndk, recipientUser);
console.log(`[getKind24RelaySet] Recipient outbox relays (used as fallback):`, recipientinboxRelays);
}
// According to NIP-A4: Messages MUST be sent to the NIP-65 inbox relays of each receiver
// and the outbox relay of the sender
const targetRelays = [...new Set([...senderoutboxRelays, ...recipientinboxRelays])];
console.log(`[getKind24RelaySet] Combined target relays:`, targetRelays);
// Prioritize common relays between sender and recipient for better privacy
const commonRelays = senderoutboxRelays.filter((relay: string) =>
recipientinboxRelays.includes(relay)
);
const senderOnlyRelays = senderoutboxRelays.filter((relay: string) =>
!recipientinboxRelays.includes(relay)
);
const recipientOnlyRelays = recipientinboxRelays.filter((relay: string) =>
!senderoutboxRelays.includes(relay)
);
console.log(`[getKind24RelaySet] Common relays:`, commonRelays);
console.log(`[getKind24RelaySet] Sender-only relays:`, senderOnlyRelays);
console.log(`[getKind24RelaySet] Recipient-only relays:`, recipientOnlyRelays);
// Prioritize: common relays first, then sender outbox, then recipient inbox
const finalRelays = [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays];
console.log(`[getKind24RelaySet] Final relay list:`, finalRelays);
return finalRelays;
}

225
src/lib/utils/notification_utils.ts

@ -0,0 +1,225 @@
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getUserMetadata, NDKRelaySetFromNDK, toNpub } from "$lib/utils/nostrUtils";
import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk";
import { searchRelays } from "$lib/consts";
import { userStore } from "$lib/stores/userStore";
import { buildCompleteRelaySet } from "$lib/utils/relay_management";
import { neventEncode } from "$lib/utils";
// AI-NOTE: Notification-specific utility functions that don't exist elsewhere
/**
* Truncates content to a specified length
*/
export function truncateContent(content: string, maxLength: number = 300): string {
if (content.length <= maxLength) return content;
return content.slice(0, maxLength) + "...";
}
/**
* Truncates rendered HTML content while preserving quote boxes
*/
export function truncateRenderedContent(renderedHtml: string, maxLength: number = 300): string {
if (renderedHtml.length <= maxLength) return renderedHtml;
const hasQuoteBoxes = renderedHtml.includes('jump-to-message');
if (hasQuoteBoxes) {
const quoteBoxPattern = /<div class="block w-fit my-2 px-3 py-2 bg-gray-200[^>]*onclick="window\.dispatchEvent\(new CustomEvent\('jump-to-message'[^>]*>[^<]*<\/div>/g;
const quoteBoxes = renderedHtml.match(quoteBoxPattern) || [];
let textOnly = renderedHtml.replace(quoteBoxPattern, '|||QUOTEBOX|||');
if (textOnly.length > maxLength) {
const availableLength = maxLength - (quoteBoxes.join('').length);
if (availableLength > 50) {
textOnly = textOnly.slice(0, availableLength) + "...";
} else {
textOnly = textOnly.slice(0, 50) + "...";
}
}
let result = textOnly;
quoteBoxes.forEach(box => {
result = result.replace('|||QUOTEBOX|||', box);
});
return result;
} else {
if (renderedHtml.includes('<')) {
const truncated = renderedHtml.slice(0, maxLength);
const lastTagStart = truncated.lastIndexOf('<');
const lastTagEnd = truncated.lastIndexOf('>');
if (lastTagStart > lastTagEnd) {
return renderedHtml.slice(0, lastTagStart) + "...";
}
return truncated + "...";
} else {
return renderedHtml.slice(0, maxLength) + "...";
}
}
}
/**
* Parses content using basic markup parser
*/
export async function parseContent(content: string): Promise<string> {
if (!content) return "";
return await parseBasicmarkup(content);
}
/**
* Renders quoted content for a message
*/
export async function renderQuotedContent(message: NDKEvent, publicMessages: NDKEvent[]): Promise<string> {
const qTags = message.getMatchingTags("q");
if (qTags.length === 0) return "";
const qTag = qTags[0];
const eventId = qTag[1];
if (eventId) {
// First try to find in local messages
let quotedMessage = publicMessages.find(msg => msg.id === eventId);
// If not found locally, fetch from relays
if (!quotedMessage) {
try {
const ndk = get(ndkInstance);
if (ndk) {
const userStoreValue = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays, ...searchRelays];
if (allRelays.length > 0) {
const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const fetchedEvent = await ndk.fetchEvent({ ids: [eventId], limit: 1 }, undefined, ndkRelaySet);
quotedMessage = fetchedEvent || undefined;
}
}
} catch (error) {
console.warn(`[renderQuotedContent] Failed to fetch quoted event ${eventId}:`, error);
}
}
if (quotedMessage) {
const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content";
const parsedContent = await parseBasicmarkup(quotedContent);
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick="window.dispatchEvent(new CustomEvent('jump-to-message', { detail: '${eventId}' }))">${parsedContent}</div>`;
} else {
// Fallback to nevent link
const nevent = neventEncode({ id: eventId } as any, []);
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick="window.location.href='/events?id=${nevent}'">Quoted message not found. Click to view event ${eventId.slice(0, 8)}...</div>`;
}
}
return "";
}
/**
* Gets notification type based on event kind
*/
export 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}`;
}
}
/**
* Fetches author profiles for a list of events
*/
export async function fetchAuthorProfiles(events: NDKEvent[]): Promise<Map<string, { name?: string; displayName?: string; picture?: string }>> {
const authorProfiles = new Map<string, { name?: string; displayName?: string; picture?: string }>();
const uniquePubkeys = new Set<string>();
events.forEach(event => {
if (event.pubkey) uniquePubkeys.add(event.pubkey);
});
const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => {
try {
const npub = toNpub(pubkey);
if (!npub) return;
// Try cache first
let profile = await getUserMetadata(npub, false);
if (profile && (profile.name || profile.displayName || profile.picture)) {
authorProfiles.set(pubkey, profile);
return;
}
// 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);
authorProfiles.set(pubkey, {
name: profileData.name,
displayName: profileData.display_name || profileData.displayName,
picture: profileData.picture || profileData.image
});
return;
}
} catch (error) {
console.warn(`[fetchAuthorProfiles] Failed to fetch profile from ${relay}:`, error);
}
}
// Try all available relays as fallback
try {
const ndk = get(ndkInstance);
if (!ndk) return;
const userStoreValue = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays];
if (allRelays.length > 0) {
const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [pubkey] },
undefined,
ndkRelaySet
);
if (profileEvent) {
const profileData = JSON.parse(profileEvent.content);
authorProfiles.set(pubkey, {
name: profileData.name,
displayName: profileData.display_name || profileData.displayName,
picture: profileData.picture || profileData.image
});
}
}
} catch (error) {
console.warn(`[fetchAuthorProfiles] Failed to fetch profile from all relays:`, error);
}
} catch (error) {
console.warn(`[fetchAuthorProfiles] Failed to fetch profile for ${pubkey}:`, error);
}
});
await Promise.allSettled(profilePromises);
return authorProfiles;
}
Loading…
Cancel
Save