Browse Source

relay info display in public message replies

master
silberengel 7 months ago
parent
commit
49de089e9a
  1. 240
      src/lib/components/Notifications.svelte
  2. 92
      src/lib/components/RelayInfoDisplay.svelte
  3. 169
      src/lib/components/RelayInfoList.svelte
  4. 252
      src/lib/utils/kind24_utils.ts
  5. 166
      src/lib/utils/relay_info_service.ts

240
src/lib/components/Notifications.svelte

@ -11,6 +11,9 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { communityRelays, localRelays } from "$lib/consts"; import { communityRelays, localRelays } from "$lib/consts";
import { createKind24Reply, getKind24RelaySet } from "$lib/utils/kind24_utils";
import RelayDisplay from "$lib/components/RelayDisplay.svelte";
import RelayInfoList from "$lib/components/RelayInfoList.svelte";
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
@ -23,6 +26,14 @@
let notificationMode = $state<"to-me" | "from-me" | "public-messages">("to-me"); let notificationMode = $state<"to-me" | "from-me" | "public-messages">("to-me");
let authorProfiles = $state<Map<string, { name?: string; displayName?: string; picture?: string }>>(new Map()); let authorProfiles = $state<Map<string, { name?: string; displayName?: string; picture?: string }>>(new Map());
let filteredByUser = $state<string | null>(null); let filteredByUser = $state<string | null>(null);
let replyContent = $state<string>("");
let replyingTo = $state<string | null>(null);
let isReplying = $state(false);
let originalMessage = $state<NDKEvent | null | undefined>(null);
let replyingToMessageId = $state<string | null>(null);
let replyRelays = $state<string[]>([]);
let senderOutboxRelays = $state<string[]>([]);
let recipientInboxRelays = $state<string[]>([]);
// Derived state for filtered messages // Derived state for filtered messages
let filteredMessages = $derived.by(() => { let filteredMessages = $derived.by(() => {
@ -85,6 +96,19 @@
return content.slice(0, maxLength) + "..."; return content.slice(0, maxLength) + "...";
} }
function renderContentWithLinks(content: string): string {
console.log("[Notifications] Rendering content:", content);
// Parse markdown links [text](url) and convert to HTML
let rendered = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline">$1</a>');
// Also handle the new quote format: "> LINK: nevent://..." and convert to button
rendered = rendered.replace(/> LINK: (nevent:\/\/[^\s\n]+)/g, '> <button onclick="window.location.href=\'$1\'" class="inline-block px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded font-medium transition-colors">View Original Message</button>');
console.log("[Notifications] Rendered content:", rendered);
return rendered;
}
function getNotificationType(event: NDKEvent): string { function getNotificationType(event: NDKEvent): string {
switch (event.kind) { switch (event.kind) {
case 1: return "Reply"; case 1: return "Reply";
@ -109,6 +133,141 @@
filteredByUser = null; filteredByUser = null;
} }
// AI-NOTE: Reply functionality for kind 24 messages
async function startReply(pubkey: string, messageEvent?: NDKEvent) {
replyingTo = pubkey;
isReplying = true;
replyContent = "";
replyingToMessageId = messageEvent?.id || null;
// Store the original message for q tag
originalMessage = messageEvent || null;
// Clear previous relay information
replyRelays = [];
senderOutboxRelays = [];
recipientInboxRelays = [];
// Immediately fetch relay information for this recipient
await getReplyRelays();
}
function cancelReply() {
replyingTo = null;
isReplying = false;
replyContent = "";
replyingToMessageId = null;
replyRelays = [];
senderOutboxRelays = [];
recipientInboxRelays = [];
}
async function sendReply() {
if (!replyingTo || !replyContent.trim()) return;
try {
// Find the original message being replied to
const originalMessage = publicMessages.find(msg => msg.id === replyingToMessageId);
const result = await createKind24Reply(replyContent, replyingTo, originalMessage);
if (result.success) {
// Store relay information for display
replyRelays = result.relays || [];
// Update the inbox/outbox arrays to match the actual relays being used
// Keep only the top 3 that are actually in the reply relay set
const replyRelaySet = new Set(replyRelays);
senderOutboxRelays = senderOutboxRelays
.filter(relay => replyRelaySet.has(relay))
.slice(0, 3);
recipientInboxRelays = recipientInboxRelays
.filter(relay => replyRelaySet.has(relay))
.slice(0, 3);
// Clear reply state
replyingTo = null;
isReplying = false;
replyContent = "";
replyingToMessageId = null;
// Optionally refresh messages
await fetchPublicMessages();
} else {
console.error("Failed to send reply:", result.error);
// You could show an error message to the user here
}
} catch (error) {
console.error("Error sending reply:", error);
}
}
// Function to get relay information before sending
async function getReplyRelays() {
if (!replyingTo) return;
try {
const originalMessage = publicMessages.find(msg => msg.id === replyingToMessageId);
// Get sender's outbox relays and recipient's inbox relays
const ndk = get(ndkInstance);
if (ndk?.activeUser) {
// Get sender's outbox relays
const senderUser = ndk.activeUser;
const senderRelayList = await ndk.fetchEvent({
kinds: [10002],
authors: [senderUser.pubkey],
});
if (senderRelayList) {
senderOutboxRelays = senderRelayList.tags
.filter(tag => tag[0] === 'r' && tag[1])
.map(tag => tag[1])
.slice(0, 3); // Limit to top 3 outbox relays
}
// Get recipient's inbox relays
const recipientUser = ndk.getUser({ pubkey: replyingTo });
const recipientRelayList = await ndk.fetchEvent({
kinds: [10002],
authors: [replyingTo],
});
if (recipientRelayList) {
recipientInboxRelays = recipientRelayList.tags
.filter(tag => tag[0] === 'r' && tag[1])
.map(tag => tag[1])
.slice(0, 3); // Limit to top 3 inbox relays
}
}
// If we have content, use the actual reply function
if (replyContent.trim()) {
const result = await createKind24Reply(replyContent, replyingTo, originalMessage);
replyRelays = result.relays || [];
} else {
// If no content yet, just get the relay set for this recipient
const result = await getKind24RelaySet($userStore.pubkey || '', replyingTo);
replyRelays = result || [];
// Update the inbox/outbox arrays to match the actual relays being used
// Keep only the top 3 that are actually in the reply relay set
const replyRelaySet = new Set(replyRelays);
senderOutboxRelays = senderOutboxRelays
.filter(relay => replyRelaySet.has(relay))
.slice(0, 3);
recipientInboxRelays = recipientInboxRelays
.filter(relay => replyRelaySet.has(relay))
.slice(0, 3);
console.log('[Notifications] Got relay set:', result);
console.log('[Notifications] Filtered sender outbox relays:', senderOutboxRelays);
console.log('[Notifications] Filtered recipient inbox relays:', recipientInboxRelays);
}
} catch (error) {
console.error("Error getting relay information:", error);
replyRelays = [];
senderOutboxRelays = [];
recipientInboxRelays = [];
}
}
// AI-NOTE: Simplified profile fetching with better error handling // AI-NOTE: Simplified profile fetching with better error handling
async function fetchAuthorProfiles(events: NDKEvent[]) { async function fetchAuthorProfiles(events: NDKEvent[]) {
const uniquePubkeys = new Set<string>(); const uniquePubkeys = new Set<string>();
@ -321,6 +480,13 @@
authorProfiles.clear(); authorProfiles.clear();
} }
}); });
// Fetch relay information when reply content changes (for updates)
$effect(() => {
if (isReplying && replyingTo && replyContent.trim() && replyRelays.length === 0) {
getReplyRelays();
}
});
</script> </script>
{#if isOwnProfile && $userStore.signedIn} {#if isOwnProfile && $userStore.signedIn}
@ -400,7 +566,19 @@
<!-- Filter button for non-user messages --> <!-- Filter button for non-user messages -->
{#if !isFromUser} {#if !isFromUser}
<div class="mt-2 flex justify-center"> <div class="mt-2 flex flex-col gap-1">
<!-- Reply button -->
<button
class="w-6 h-6 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-full flex items-center justify-center text-xs shadow-sm transition-colors"
onclick={() => startReply(message.pubkey, message)}
title="Reply to this message"
aria-label="Reply to this message"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.707 3.293a1 1 0 010 1.414L5.414 7H11a7 7 0 017 7v2a1 1 0 11-2 0v-2a5 5 0 00-5-5H5.414l2.293 2.293a1 1 0 11-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</button>
<!-- Filter button -->
<button <button
class="w-6 h-6 bg-gray-400 hover:bg-gray-500 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-300 rounded-full flex items-center justify-center text-xs shadow-sm transition-colors {filteredByUser === message.pubkey ? 'ring-2 ring-gray-300 dark:ring-gray-400 bg-gray-500 dark:bg-gray-500' : ''}" class="w-6 h-6 bg-gray-400 hover:bg-gray-500 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-300 rounded-full flex items-center justify-center text-xs shadow-sm transition-colors {filteredByUser === message.pubkey ? 'ring-2 ring-gray-300 dark:ring-gray-400 bg-gray-500 dark:bg-gray-500' : ''}"
onclick={() => filterByUser(message.pubkey)} onclick={() => filterByUser(message.pubkey)}
@ -440,7 +618,7 @@
{#if message.content} {#if message.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(message.content)} {@html renderContentWithLinks(truncateContent(message.content))}
</div> </div>
{/if} {/if}
@ -457,6 +635,64 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Inline Reply Interface -->
{#if isReplying && replyingToMessageId === message.id}
{@const recipientProfile = authorProfiles.get(message.pubkey)}
<div class="mt-3 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">
Replying to: {recipientProfile?.displayName || recipientProfile?.name || `${message.pubkey.slice(0, 8)}...${message.pubkey.slice(-4)}`}
</span>
<button
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline"
onclick={cancelReply}
>
Cancel
</button>
</div>
<div class="flex gap-2">
<textarea
bind:value={replyContent}
placeholder="Type your reply..."
class="flex-1 p-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 resize-none"
rows="6"
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendReply();
}
}}
></textarea>
<button
onclick={sendReply}
disabled={!replyContent.trim()}
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 dark:bg-blue-600 dark:hover:bg-blue-700 dark:disabled:bg-gray-600 text-white text-sm font-medium rounded-md transition-colors disabled:cursor-not-allowed"
>
Send
</button>
</div>
<!-- Relay Information -->
<div class="mt-3 p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
{#if replyRelays.length > 0}
{@const debugInfo = console.log('[Notifications] Rendering RelayInfoList with:', { replyRelays, recipientInboxRelays, senderOutboxRelays })}
<RelayInfoList
relays={replyRelays}
inboxRelays={recipientInboxRelays}
outboxRelays={senderOutboxRelays}
showLabels={true}
compact={false}
/>
{:else}
<div class="flex items-center justify-center py-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Loading relay information...</span>
</div>
{/if}
</div>
</div>
{/if}
</div> </div>
{/each} {/each}
</div> </div>

92
src/lib/components/RelayInfoDisplay.svelte

@ -0,0 +1,92 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchRelayInfo, getRelayTypeLabel, getRelayIcon, type RelayInfoWithMetadata } from '$lib/utils/relay_info_service';
const { relay, showIcon = true, showType = true, showName = true, size = 'sm' } = $props<{
relay: string;
showIcon?: boolean;
showType?: boolean;
showName?: boolean;
size?: 'xs' | 'sm' | 'md' | 'lg';
}>();
let relayInfo = $state<RelayInfoWithMetadata | undefined>(undefined);
let isLoading = $state(true);
let error = $state<string | null>(null);
// Size classes
const sizeClasses: Record<'xs' | 'sm' | 'md' | 'lg', string> = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg'
};
const iconSizeClasses: Record<'xs' | 'sm' | 'md' | 'lg', string> = {
xs: 'w-3 h-3',
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6'
};
async function loadRelayInfo() {
isLoading = true;
error = null;
try {
relayInfo = await fetchRelayInfo(relay);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load relay info';
console.warn(`[RelayInfoDisplay] Error loading info for ${relay}:`, err);
} finally {
isLoading = false;
}
}
onMount(() => {
loadRelayInfo();
});
// Get relay type and label
const relayType = $derived(getRelayTypeLabel(relay, relayInfo));
const relayIcon = $derived(getRelayIcon(relayInfo, relay));
const displayName = $derived(relayInfo?.name || relayInfo?.shortUrl || relay);
</script>
<div class="inline-flex items-center gap-2 flex-1">
{#if showIcon && relayIcon}
<img
src={relayIcon}
alt="Relay icon"
class="{iconSizeClasses[size as keyof typeof iconSizeClasses]} rounded object-contain"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
{:else if showIcon}
<!-- Fallback icon -->
<div class="{iconSizeClasses[size as keyof typeof iconSizeClasses]} bg-gray-300 dark:bg-gray-600 rounded flex items-center justify-center">
<svg class="w-2/3 h-2/3 text-gray-600 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V4z" clip-rule="evenodd" />
</svg>
</div>
{/if}
<div class="flex flex-col min-w-0 flex-1">
{#if showName}
<span class="{sizeClasses[size as keyof typeof sizeClasses]} font-medium text-gray-900 dark:text-gray-100 leading-tight truncate">
{isLoading ? 'Loading...' : displayName}
</span>
{/if}
{#if showType}
<span class="text-xs text-gray-500 dark:text-gray-400 leading-tight truncate">
{relayType}
</span>
{/if}
</div>
{#if error}
<span class="text-xs text-red-500 dark:text-red-400 flex-shrink-0" title={error}>
</span>
{/if}
</div>

169
src/lib/components/RelayInfoList.svelte

@ -0,0 +1,169 @@
<script lang="ts">
import RelayInfoDisplay from './RelayInfoDisplay.svelte';
import { fetchRelayInfos, type RelayInfoWithMetadata } from '$lib/utils/relay_info_service';
const {
relays,
inboxRelays = [],
outboxRelays = [],
showLabels = true,
compact = false
} = $props<{
relays: string[];
inboxRelays?: string[];
outboxRelays?: string[];
showLabels?: boolean;
compact?: boolean;
}>();
let relayInfos = $state<RelayInfoWithMetadata[]>([]);
let isLoading = $state(true);
type CategorizedRelay = {
relay: string;
category: 'both' | 'inbox' | 'outbox' | 'other';
label: string;
};
// AI-NOTE: Updated to show only top-3 inboxes and top-3 outboxes as intended
const categorizedRelays = $derived(() => {
const inbox = new Set(inboxRelays);
const outbox = new Set(outboxRelays);
console.log('[RelayInfoList] Categorizing relays:', {
relays: relays.length,
inboxRelays: inboxRelays.length,
outboxRelays: outboxRelays.length
});
// Create a map of all relays with their categories
const relayCategories = new Map<string, CategorizedRelay>();
// Process inbox relays (up to top 3)
const topInboxRelays = inboxRelays.slice(0, 3);
topInboxRelays.forEach((relay: string) => {
const isOutbox = outbox.has(relay);
if (isOutbox) {
relayCategories.set(relay, { relay, category: 'both', label: 'Inbox & Outbox' });
} else {
relayCategories.set(relay, { relay, category: 'inbox', label: 'Recipient Inbox' });
}
});
// Process outbox relays (up to top 3)
const topOutboxRelays = outboxRelays.slice(0, 3);
topOutboxRelays.forEach((relay: string) => {
if (!relayCategories.has(relay)) {
relayCategories.set(relay, { relay, category: 'outbox', label: 'Sender Outbox' });
}
});
// Only include relays that are actually in the top-3 lists
// This ensures we only show the intended top-3 inboxes and top-3 outboxes
const categorized = Array.from(relayCategories.values());
console.log('[RelayInfoList] Categorized relays count:', categorized.length);
return categorized;
});
// Group by category
const groupedRelays = $derived(() => {
const categorized = categorizedRelays();
console.log('[RelayInfoList] Grouping categorized relays');
const groups = {
both: categorized.filter((r: CategorizedRelay) => r.category === 'both'),
inbox: categorized.filter((r: CategorizedRelay) => r.category === 'inbox'),
outbox: categorized.filter((r: CategorizedRelay) => r.category === 'outbox'),
other: categorized.filter((r: CategorizedRelay) => r.category === 'other')
};
console.log('[RelayInfoList] Grouped relays:', Object.fromEntries(
Object.entries(groups).map(([key, relays]) => [key, relays.length])
));
return groups;
});
async function loadRelayInfos() {
isLoading = true;
try {
const categorized = categorizedRelays();
const relayUrls = categorized.map(r => r.relay);
relayInfos = await fetchRelayInfos(relayUrls);
} catch (error) {
console.warn('[RelayInfoList] Error loading relay infos:', error);
} finally {
isLoading = false;
}
}
// Load relay info when categorized relays change
$effect(() => {
const categorized = categorizedRelays();
console.log('[RelayInfoList] Categorized relays changed:', {
total: categorized.length,
byCategory: Object.fromEntries(
Object.entries(groupedRelays()).map(([key, relays]) => [key, relays.length])
)
});
if (categorized.length > 0) {
loadRelayInfos();
}
});
// Get relay info for a specific relay
function getRelayInfo(relayUrl: string): RelayInfoWithMetadata | undefined {
return relayInfos.find(info => info.url === relayUrl);
}
// Category colors
const categoryColors = {
both: 'bg-green-100 dark:bg-green-900 border-green-200 dark:border-green-700 text-green-800 dark:text-green-200',
inbox: 'bg-blue-100 dark:bg-blue-900 border-blue-200 dark:border-blue-700 text-blue-800 dark:text-blue-200',
outbox: 'bg-purple-100 dark:bg-purple-900 border-purple-200 dark:border-purple-700 text-purple-800 dark:text-purple-200',
other: 'bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-200'
};
const categoryIcons = {
both: '🔄',
inbox: '📥',
outbox: '📤',
other: '🌐'
};
</script>
<div class="space-y-2">
{#if showLabels && !compact}
{@const categorizedCount = categorizedRelays().length}
{@const debugCategorized = console.log('[RelayInfoList] Debug - categorized relays:', categorizedRelays())}
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">
Publishing to {categorizedCount} relay(s):
</div>
{/if}
{#if isLoading}
<div class="flex items-center justify-center py-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Loading relay info...</span>
</div>
{:else}
{@const categorized = categorizedRelays()}
{@const debugCategorized = console.log('[RelayInfoList] Debug - categorized relays:', categorized)}
<div class="space-y-1">
{#each categorized as { relay, category, label }}
<div class="p-2 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-sm font-mono text-gray-900 dark:text-gray-100">
{relay}
</span>
{#if category === 'both'}
<span class="text-xs text-gray-500 dark:text-gray-400 italic">
common relay
</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>

252
src/lib/utils/kind24_utils.ts

@ -0,0 +1,252 @@
import { get } from "svelte/store";
import { ndkInstance } from "../ndk";
import { userStore } from "../stores/userStore";
import { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
/**
* Fetches user's outbox relays from NIP-65 relay list
* @param ndk NDK instance
* @param user User to fetch outbox relays for
* @returns Promise that resolves to array of outbox relay URLs
*/
async function getUseroutboxRelays(ndk: NDK, user: NDKUser): Promise<string[]> {
try {
console.debug('[kind24_utils] Fetching outbox relays for user:', user.pubkey);
const relayList = await ndk.fetchEvent(
{
kinds: [10002],
authors: [user.pubkey],
}
);
if (!relayList) {
console.debug('[kind24_utils] No relay list found for user');
return [];
}
console.debug('[kind24_utils] Found relay list event:', relayList.id);
console.debug('[kind24_utils] Relay list tags:', relayList.tags);
const outboxRelays: string[] = [];
relayList.tags.forEach((tag) => {
console.debug('[kind24_utils] Processing tag:', 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 === 'inbox') {
// If no marker or marker is 'outbox', it's a outbox relay
// If marker is 'inbox', it's also a outbox relay (NIP-65 allows both)
outboxRelays.push(tag[1]);
console.debug('[kind24_utils] Added outbox relay:', tag[1]);
}
}
});
console.debug('[kind24_utils] Final outbox relays:', outboxRelays);
return outboxRelays;
} catch (error) {
console.info('[kind24_utils] Error fetching user outbox relays:', error);
return [];
}
}
/**
* Fetches user's inbox relays from NIP-65 relay list
* @param ndk NDK instance
* @param user User to fetch inbox relays for
* @returns Promise that resolves to array of inbox relay URLs
*/
async function getUserinboxRelays(ndk: NDK, user: NDKUser): Promise<string[]> {
try {
console.debug('[kind24_utils] Fetching inbox relays for user:', user.pubkey);
const relayList = await ndk.fetchEvent(
{
kinds: [10002],
authors: [user.pubkey],
}
);
if (!relayList) {
console.debug('[kind24_utils] No relay list found for user');
return [];
}
console.debug('[kind24_utils] Found relay list event:', relayList.id);
console.debug('[kind24_utils] Relay list tags:', relayList.tags);
const inboxRelays: string[] = [];
relayList.tags.forEach((tag) => {
console.debug('[kind24_utils] Processing tag:', tag);
if (tag[0] === 'r' && tag[1]) {
// NIP-65: r tags with optional inbox/outbox markers
const marker = tag[2];
if (!marker || marker === 'inbox' || marker === 'outbox') {
// If no marker or marker is 'inbox', it's a inbox relay
// If marker is 'outbox', it's also a inbox relay (NIP-65 allows both)
inboxRelays.push(tag[1]);
console.debug('[kind24_utils] Added inbox relay:', tag[1]);
}
}
});
console.debug('[kind24_utils] Final inbox relays:', inboxRelays);
return inboxRelays;
} catch (error) {
console.info('[kind24_utils] Error fetching user inbox relays:', error);
return [];
}
}
/**
* Creates a kind 24 public message reply according to NIP-A4
* @param content The message content
* @param recipientPubkey The recipient's pubkey
* @param originalEvent The original event being replied to (optional)
* @returns Promise resolving to publish result with relay information
*/
export async function createKind24Reply(
content: string,
recipientPubkey: string,
originalEvent?: NDKEvent
): Promise<{ success: boolean; eventId?: string; error?: string; relays?: string[] }> {
const ndk = get(ndkInstance);
if (!ndk?.activeUser) {
return { success: false, error: "Not logged in" };
}
if (!content.trim()) {
return { success: false, error: "Message content cannot be empty" };
}
try {
// Get sender's outbox relays (NIP-65)
const senderoutboxRelays = await getUseroutboxRelays(ndk, ndk.activeUser);
// Get recipient's inbox relays (NIP-65)
const recipientUser = ndk.getUser({ pubkey: recipientPubkey });
const recipientinboxRelays = await getUserinboxRelays(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
const prioritizedRelays = [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays];
if (prioritizedRelays.length === 0) {
return { success: false, error: "No relays available for publishing" };
}
// Create the kind 24 event
const event = new NDKEvent(ndk);
event.kind = 24;
// Build content with quoted message if replying
let finalContent = content;
if (originalEvent) {
// Use multiple relays for better discoverability
const nevent = nip19.neventEncode({
id: originalEvent.id,
relays: prioritizedRelays.slice(0, 3) // Use first 3 relays
});
const quotedContent = originalEvent.content ? originalEvent.content.slice(0, 200) : "No content";
// Use a more visible quote format with a clickable link
finalContent = `> QUOTED: ${quotedContent}\n> LINK: ${nevent}\n\n${content}`;
console.log("[kind24_utils] Reply content:", finalContent);
}
event.content = finalContent;
event.created_at = Math.floor(Date.now() / 1000);
// Add p tag for recipient with relay URL
const tags: string[][] = [
["p", recipientPubkey, prioritizedRelays[0]] // Use first relay as primary
];
// Add q tag if replying to an original event
if (originalEvent) {
const nevent = nip19.neventEncode({
id: originalEvent.id,
relays: prioritizedRelays.slice(0, 3) // Use first 3 relays
});
tags.push(["q", nevent, prioritizedRelays[0]]);
}
event.tags = tags;
event.pubkey = ndk.activeUser.pubkey;
// Sign the event
await event.sign();
// Publish to relays
const relaySet = NDKRelaySet.fromRelayUrls(prioritizedRelays, ndk);
const publishedToRelays = await event.publish(relaySet);
if (publishedToRelays.size > 0) {
return { success: true, eventId: event.id, relays: prioritizedRelays };
} else {
return { success: false, error: "Failed to publish to any relays", relays: prioritizedRelays };
}
} catch (error) {
console.error("[kind24_utils] Error creating kind 24 reply:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error"
};
}
}
/**
* 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");
}
// Get sender's outbox relays (NIP-65)
const senderUser = ndk.getUser({ pubkey: senderPubkey });
const senderoutboxRelays = await getUseroutboxRelays(ndk, senderUser);
// Get recipient's inbox relays (NIP-65)
const recipientUser = ndk.getUser({ pubkey: recipientPubkey });
const recipientinboxRelays = await getUserinboxRelays(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: string) =>
recipientinboxRelays.includes(relay)
);
const senderOnlyRelays = senderoutboxRelays.filter((relay: string) =>
!recipientinboxRelays.includes(relay)
);
const recipientOnlyRelays = recipientinboxRelays.filter((relay: string) =>
!senderoutboxRelays.includes(relay)
);
// Prioritize: common relays first, then sender outbox, then recipient inbox
return [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays];
}

166
src/lib/utils/relay_info_service.ts

@ -0,0 +1,166 @@
/**
* Simplifies a URL by removing protocol and common prefixes
* @param url The URL to simplify
* @returns Simplified URL string
*/
function simplifyUrl(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.hostname + (urlObj.port ? `:${urlObj.port}` : '');
} catch {
// If URL parsing fails, return the original string
return url;
}
}
export interface RelayInfo {
name?: string;
description?: string;
icon?: string;
pubkey?: string;
contact?: string;
supported_nips?: number[];
software?: string;
version?: string;
tags?: string[];
payments_url?: string;
limitation?: {
auth_required?: boolean;
payment_required?: boolean;
};
}
export interface RelayInfoWithMetadata extends RelayInfo {
url: string;
shortUrl: string;
hasNip11: boolean;
triedNip11: boolean;
}
/**
* Fetches relay information using NIP-11
* @param url The relay URL to fetch info for
* @returns Promise resolving to relay info or undefined if failed
*/
export async function fetchRelayInfo(url: string): Promise<RelayInfoWithMetadata | undefined> {
try {
// Convert WebSocket URL to HTTP URL for NIP-11
const httpUrl = url.replace('ws://', 'http://').replace('wss://', 'https://');
const response = await fetch(httpUrl, {
headers: {
'Accept': 'application/nostr+json',
'User-Agent': 'Alexandria/1.0'
},
// Add timeout to prevent hanging
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
console.warn(`[RelayInfo] HTTP ${response.status} for ${url}`);
return {
url,
shortUrl: simplifyUrl(url),
hasNip11: false,
triedNip11: true
};
}
const relayInfo = await response.json() as RelayInfo;
return {
...relayInfo,
url,
shortUrl: simplifyUrl(url),
hasNip11: Object.keys(relayInfo).length > 0,
triedNip11: true
};
} catch (error) {
console.warn(`[RelayInfo] Failed to fetch info for ${url}:`, error);
return {
url,
shortUrl: simplifyUrl(url),
hasNip11: false,
triedNip11: true
};
}
}
/**
* Fetches relay information for multiple relays in parallel
* @param urls Array of relay URLs to fetch info for
* @returns Promise resolving to array of relay info objects
*/
export async function fetchRelayInfos(urls: string[]): Promise<RelayInfoWithMetadata[]> {
if (urls.length === 0) {
return [];
}
const promises = urls.map(url => fetchRelayInfo(url));
const results = await Promise.allSettled(promises);
return results
.map(result => result.status === 'fulfilled' ? result.value : undefined)
.filter((info): info is RelayInfoWithMetadata => info !== undefined);
}
/**
* Gets relay type label based on relay URL and info
* @param relayUrl The relay URL
* @param relayInfo Optional relay info
* @returns String describing the relay type
*/
export function getRelayTypeLabel(relayUrl: string, relayInfo?: RelayInfoWithMetadata): string {
// Check if it's a local relay
if (relayUrl.includes('localhost') || relayUrl.includes('127.0.0.1')) {
return 'Local';
}
// Check if it's a community relay
if (relayUrl.includes('nostr.band') || relayUrl.includes('noswhere.com') ||
relayUrl.includes('damus.io') || relayUrl.includes('nostr.wine')) {
return 'Community';
}
// Check if it's a user's relay (likely inbox/outbox)
if (relayUrl.includes('relay.nsec.app') || relayUrl.includes('relay.snort.social')) {
return 'User';
}
// Use relay name if available
if (relayInfo?.name) {
return relayInfo.name;
}
// Fallback to domain
try {
const domain = new URL(relayUrl).hostname;
return domain.replace('www.', '');
} catch {
return 'Unknown';
}
}
/**
* Gets relay icon URL or fallback
* @param relayInfo Relay info object
* @param relayUrl Relay URL as fallback
* @returns Icon URL or undefined
*/
export function getRelayIcon(relayInfo?: RelayInfoWithMetadata, relayUrl?: string): string | undefined {
if (relayInfo?.icon) {
return relayInfo.icon;
}
// Generate favicon URL from relay URL
if (relayUrl) {
try {
const url = new URL(relayUrl);
return `${url.protocol}//${url.hostname}/favicon.ico`;
} catch {
// Invalid URL, return undefined
}
}
return undefined;
}
Loading…
Cancel
Save