From de183ed6ffd7cfe9af3f305eec52b3b19c09cf19 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sat, 9 Aug 2025 08:48:02 +0200 Subject: [PATCH] reinstate missing New Message button --- src/lib/components/Notifications.svelte | 754 ++++++++++++++++++------ src/lib/utils/profile_search.ts | 27 +- 2 files changed, 589 insertions(+), 192 deletions(-) diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 4488952..45ee83e 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -17,6 +17,7 @@ import { Modal, Button } from "flowbite-svelte"; import { searchProfiles } from "$lib/utils/search_utility"; import type { NostrProfile } from "$lib/utils/search_types"; + import { PlusOutline } from "flowbite-svelte-icons"; const { event } = $props<{ event: NDKEvent }>(); @@ -45,14 +46,26 @@ let notificationMode = $state<"to-me" | "from-me" | "public-messages">("to-me"); let authorProfiles = $state>(new Map()); let filteredByUser = $state(null); - let replyContent = $state(""); - let replyingTo = $state(null); - let isReplying = $state(false); - let originalMessage = $state(null); - let replyingToMessageId = $state(null); - let replyRelays = $state([]); - let senderOutboxRelays = $state([]); - let recipientInboxRelays = $state([]); + + + // New Message Modal state + let showNewMessageModal = $state(false); + let newMessageContent = $state(""); + let selectedRecipients = $state([]); + let newMessageRelays = $state([]); + let isComposingMessage = $state(false); + let replyToMessage = $state(null); + let quotedContent = $state(""); + + // Recipient Selection Modal state + let showRecipientModal = $state(false); + let recipientSearch = $state(""); + let recipientResults = $state([]); + let recipientLoading = $state(false); + let recipientSearchInput = $state(); + let recipientSearchTimeout: ReturnType | null = null; + let recipientCommunityStatus: Record = $state({}); + let isRecipientSearching = $state(false); // Derived state for filtered messages let filteredMessages = $derived.by(() => { @@ -121,9 +134,10 @@ // Handle quote format and convert to small gray bars like Jumble const patterns = [ - /> QUOTED: ([^•]*?) • LINK:\s*\n(nevent[^\s]*)/g, - /> QUOTED: ([^\n]*?)\n> LINK: (nevent[^\s]*)/g, - /> QUOTED: ([^•]*?) • LINK:\s*(nevent[^\s]*)/g, + /> QUOTED: ([^•]*?) • LINK:\s*\n((?:nostr:)?nevent[^\s]*)/g, + /> QUOTED: ([^\n]*?)\n> LINK: ((?:nostr:)?nevent[^\s]*)/g, + /> QUOTED: ([^•]*?) • LINK:\s*((?:nostr:)?nevent[^\s]*)/g, + /> QUOTED: ([^•]*?) • LINK: ((?:nostr:)?nevent[^\s]*)/g, // Without optional whitespace ]; for (const pattern of patterns) { @@ -199,139 +213,301 @@ 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 = []; + + + // AI-NOTE: New Message Modal Functions + function openNewMessageModal(messageToReplyTo?: NDKEvent) { + showNewMessageModal = true; + newMessageContent = ""; + selectedRecipients = []; + newMessageRelays = []; + isComposingMessage = false; + replyToMessage = messageToReplyTo || null; - // Immediately fetch relay information for this recipient - await getReplyRelays(); + // If replying, set up the quote and pre-select all original recipients plus sender + if (messageToReplyTo) { + // Store clean content for UI display (no markdown formatting) + quotedContent = messageToReplyTo.content.length > 200 + ? messageToReplyTo.content.slice(0, 200) + "..." + : messageToReplyTo.content; + + // Collect all recipients: original sender + all p-tag recipients + const recipientPubkeys = new Set(); + + // Add the original sender + recipientPubkeys.add(messageToReplyTo.pubkey); + + // Add all p-tag recipients from the original message + const pTags = messageToReplyTo.getMatchingTags("p"); + pTags.forEach(tag => { + if (tag[1]) { + recipientPubkeys.add(tag[1]); + } + }); + + // Remove the current user from recipients (don't reply to yourself) + const currentUserPubkey = $userStore.pubkey; + if (currentUserPubkey) { + recipientPubkeys.delete(currentUserPubkey); + } + + // Build the recipient list with profile information + selectedRecipients = Array.from(recipientPubkeys).map(pubkey => { + const profile = authorProfiles.get(pubkey); + return { + pubkey: pubkey, + name: profile?.name || "", + displayName: profile?.displayName || "", + picture: profile?.picture || "", + about: "", // We don't store about in authorProfiles + nip05: "", // We don't store nip05 in authorProfiles + }; + }).filter(recipient => recipient.pubkey); // Ensure we have valid pubkeys + + console.log(`Pre-loaded ${selectedRecipients.length} recipients for reply:`, selectedRecipients.map(r => r.displayName || r.name || r.pubkey?.slice(0, 8))); + } else { + quotedContent = ""; + } + } + + function closeNewMessageModal() { + showNewMessageModal = false; + newMessageContent = ""; + selectedRecipients = []; + newMessageRelays = []; + isComposingMessage = false; + replyToMessage = null; + quotedContent = ""; + } + + // AI-NOTE: Recipient Selection Modal Functions + function openRecipientModal() { + showRecipientModal = true; + recipientSearch = ""; + recipientResults = []; + recipientLoading = false; + recipientCommunityStatus = {}; + isRecipientSearching = false; + // Focus the search input after a brief delay to ensure modal is rendered + setTimeout(() => { + recipientSearchInput?.focus(); + }, 100); } - function cancelReply() { - replyingTo = null; - isReplying = false; - replyContent = ""; - replyingToMessageId = null; - replyRelays = []; - senderOutboxRelays = []; - recipientInboxRelays = []; + function closeRecipientModal() { + showRecipientModal = false; + recipientSearch = ""; + recipientResults = []; + recipientLoading = false; + recipientCommunityStatus = {}; + isRecipientSearching = false; + + // Clear any pending search timeout + if (recipientSearchTimeout) { + clearTimeout(recipientSearchTimeout); + recipientSearchTimeout = null; + } } - async function sendReply() { - if (!replyingTo || !replyContent.trim()) return; + async function searchRecipients() { + if (!recipientSearch.trim()) { + recipientResults = []; + recipientCommunityStatus = {}; + return; + } + + // Prevent multiple concurrent searches + if (isRecipientSearching) { + return; + } + + console.log("Starting recipient search for:", recipientSearch.trim()); + + // Set loading state + recipientLoading = true; + isRecipientSearching = true; 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 - } + console.log("Recipient search promise created, waiting for result..."); + const result = await searchProfiles(recipientSearch.trim()); + console.log("Recipient search completed, found profiles:", result.profiles.length); + console.log("Profile details:", result.profiles); + console.log("Community status:", result.Status); + + // Update state + recipientResults = result.profiles; + recipientCommunityStatus = result.Status; + + console.log( + "State updated - recipientResults length:", + recipientResults.length, + ); + console.log( + "State updated - recipientCommunityStatus keys:", + Object.keys(recipientCommunityStatus), + ); } catch (error) { - console.error("Error sending reply:", error); + console.error("Error searching recipients:", error); + recipientResults = []; + recipientCommunityStatus = {}; + } finally { + recipientLoading = false; + isRecipientSearching = false; + console.log( + "Recipient search finished - loading:", + recipientLoading, + "searching:", + isRecipientSearching, + ); + } + } + + // Reactive search with debouncing + $effect(() => { + // Clear existing timeout + if (recipientSearchTimeout) { + clearTimeout(recipientSearchTimeout); + } + + // If search is empty, clear results immediately + if (!recipientSearch.trim()) { + recipientResults = []; + recipientCommunityStatus = {}; + recipientLoading = false; + return; + } + + // Set loading state immediately for better UX + recipientLoading = true; + + // Debounce the search with 300ms delay + recipientSearchTimeout = setTimeout(() => { + searchRecipients(); + }, 300); + }); + + function selectRecipient(profile: NostrProfile) { + // Check if recipient is already selected + if (selectedRecipients.some(r => r.pubkey === profile.pubkey)) { + console.log("Recipient already selected:", profile.displayName || profile.name); + return; } + + // Add recipient to selection + selectedRecipients = [...selectedRecipients, profile]; + console.log("Selected recipient:", profile.displayName || profile.name); + + // Close the recipient modal (New Message modal stays open) + closeRecipientModal(); } - // Function to get relay information before sending - async function getReplyRelays() { - if (!replyingTo) return; + async function sendNewMessage() { + if (!newMessageContent.trim() || selectedRecipients.length === 0) return; try { - const originalMessage = publicMessages.find(msg => msg.id === replyingToMessageId); + isComposingMessage = true; - // 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 - } + // Create p-tags for all recipients + const pTags = selectedRecipients.map(recipient => ["p", recipient.pubkey!]); + + // Get all recipient pubkeys for relay calculation + const recipientPubkeys = selectedRecipients.map(r => r.pubkey!); + + // Calculate relay set using the same logic as kind24_utils + const senderPubkey = $userStore.pubkey; + if (!senderPubkey) { + throw new Error("No sender pubkey available"); } - // If we have content, use the actual reply function - if (replyContent.trim()) { - const result = await createKind24Reply(replyContent, replyingTo, originalMessage); - replyRelays = result.relays || []; + // Get relay sets for all recipients and combine them + const relaySetPromises = recipientPubkeys.map(recipientPubkey => + getKind24RelaySet(senderPubkey, recipientPubkey) + ); + const relaySets = await Promise.all(relaySetPromises); + + // Combine and deduplicate all relay sets + const allRelays = relaySets.flat(); + const uniqueRelays = [...new Set(allRelays)]; + newMessageRelays = uniqueRelays; + + // Create the kind 24 event with quoted content if replying + let finalContent = newMessageContent; + if (replyToMessage && quotedContent) { + // Generate the markdown quote format for the actual message + const neventUrl = getNeventUrl(replyToMessage); + const markdownQuote = `> QUOTED: ${quotedContent} • LINK: ${neventUrl}`; + finalContent = markdownQuote + "\n\n" + newMessageContent; + } + + const eventData = { + kind: 24, + content: finalContent, + tags: pTags, + pubkey: $userStore.pubkey || '', + created_at: Math.floor(Date.now() / 1000) + }; + + // Sign the event + let signedEvent; + if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) { + signedEvent = await window.nostr.signEvent(eventData); } 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); - + throw new Error("No signing method available"); + } + // Publish to relays using WebSocket pool like other components + const { WebSocketPool } = await import("$lib/data_structures/websocket_pool"); + let publishedToAny = false; + + for (const relayUrl of newMessageRelays) { + try { + const ws = await WebSocketPool.instance.acquire(relayUrl); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + WebSocketPool.instance.release(ws); + reject(new Error("Timeout")); + }, 5000); + + ws.onmessage = (e) => { + const [type, id, ok, message] = JSON.parse(e.data); + if (type === "OK" && id === signedEvent.id) { + clearTimeout(timeout); + if (ok) { + publishedToAny = true; + WebSocketPool.instance.release(ws); + resolve(); + } else { + WebSocketPool.instance.release(ws); + reject(new Error(message)); + } + } + }; + + ws.send(JSON.stringify(["EVENT", signedEvent])); + }); + } catch (e) { + console.warn(`Failed to publish to ${relayUrl}:`, e); + } + } + + if (publishedToAny) { + // Close modal and refresh messages + closeNewMessageModal(); + await fetchPublicMessages(); + } else { + throw new Error("Failed to publish to any relay"); } } catch (error) { - console.error("Error getting relay information:", error); - replyRelays = []; - senderOutboxRelays = []; - recipientInboxRelays = []; + console.error("Error sending new message:", error); + // You could show an error message to the user here + } finally { + isComposingMessage = false; } } + + // AI-NOTE: Simplified profile fetching with better error handling async function fetchAuthorProfiles(events: NDKEvent[]) { const uniquePubkeys = new Set(); @@ -545,10 +721,30 @@ } }); - // Fetch relay information when reply content changes (for updates) + + + // Calculate relay set when recipients change $effect(() => { - if (isReplying && replyingTo && replyContent.trim() && replyRelays.length === 0) { - getReplyRelays(); + const senderPubkey = $userStore.pubkey; + if (selectedRecipients.length > 0 && senderPubkey) { + const recipientPubkeys = selectedRecipients.map(r => r.pubkey!); + + // Get relay sets for all recipients and combine them + const relaySetPromises = recipientPubkeys.map(recipientPubkey => + getKind24RelaySet(senderPubkey, recipientPubkey) + ); + + Promise.all(relaySetPromises).then(relaySets => { + // Combine and deduplicate all relay sets + const allRelays = relaySets.flat(); + const uniqueRelays = [...new Set(allRelays)]; + newMessageRelays = uniqueRelays; + }).catch(error => { + console.error("Error getting relay set:", error); + newMessageRelays = []; + }); + } else { + newMessageRelays = []; } }); @@ -558,6 +754,18 @@
Notifications +
+ + +
{#each ["to-me", "from-me", "public-messages"] as mode} @@ -569,6 +777,7 @@ {modeLabel} {/each} +
@@ -634,12 +843,16 @@ @@ -697,63 +910,6 @@ - - {#if isReplying && replyingToMessageId === message.id} - {@const recipientProfile = authorProfiles.get(message.pubkey)} -
-
- - Replying to: {recipientProfile?.displayName || recipientProfile?.name || `${message.pubkey.slice(0, 8)}...${message.pubkey.slice(-4)}`} - - -
-
- - -
- - -
- {#if replyRelays.length > 0} - - - {:else} -
-
- Loading relay information... -
- {/if} -
-
- {/if} {/each} @@ -845,4 +1001,236 @@ {/if} {/if} + + + +
+
+

+ {replyToMessage ? 'Reply to Message' : 'New Public Message'} +

+
+ + + {#if quotedContent} +
+
Replying to:
+
+ {@html renderContentWithLinks(quotedContent)} +
+
+ {/if} + + +
+
+ + Sending to {selectedRecipients.length} recipient{selectedRecipients.length !== 1 ? 's' : ''}: + + +
+ + {#if selectedRecipients.length === 0} +
+

+ No recipients selected. Click "Edit Recipients" to add recipients. +

+
+ {:else} +
+ {#each selectedRecipients as recipient} + + {recipient.displayName || recipient.name || `${recipient.pubkey?.slice(0, 8)}...`} + + + {/each} +
+ {/if} +
+ + + {#if selectedRecipients.length > 0 && newMessageRelays.length > 0} +
+ + Publishing to {newMessageRelays.length} relay{newMessageRelays.length !== 1 ? 's' : ''}: + +
+
+ {#each newMessageRelays as relay} +
+ {relay} +
+ {/each} +
+
+
+ {/if} + + +
+ + +
+ + +
+ + +
+
+
+ + + +
+
+

Select Recipients

+
+ +
+
+ + {#if recipientLoading} +
+
+
+ {/if} +
+ + {#if recipientResults.length > 0} +
+
    + {#each recipientResults as profile} + {@const isAlreadySelected = selectedRecipients.some(r => r.pubkey === profile.pubkey)} + + {/each} +
+
+ {:else if recipientSearch.trim()} +
No results found
+ {:else} +
+ Enter a search term to find users +
+ {/if} +
+
+
{/if} \ No newline at end of file diff --git a/src/lib/utils/profile_search.ts b/src/lib/utils/profile_search.ts index eeac332..ecf43ec 100644 --- a/src/lib/utils/profile_search.ts +++ b/src/lib/utils/profile_search.ts @@ -1,8 +1,8 @@ -import { ndkInstance } from "../ndk.ts"; +import { ndkInstance, activeInboxRelays } from "../ndk.ts"; import { getUserMetadata, getNpubFromNip05 } from "./nostrUtils.ts"; import NDK, { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; import { searchCache } from "./searchCache.ts"; -import { communityRelays, secondaryRelays } from "../consts.ts"; +import { searchRelays, communityRelays, secondaryRelays } from "../consts.ts"; import { get } from "svelte/store"; import type { NostrProfile, ProfileSearchResult } from "./search_types.ts"; import { @@ -264,12 +264,21 @@ async function quickRelaySearch( const normalizedSearchTerm = normalizeSearchTerm(searchTerm); console.log("Normalized search term for relay search:", normalizedSearchTerm); - // Use all profile relays for better coverage - const quickRelayUrls = [...communityRelays, ...secondaryRelays]; // Use all available relays - console.log("Using all relays for search:", quickRelayUrls); + // Use search relays (optimized for profiles) + user's inbox relays + community relays + const userInboxRelays = get(activeInboxRelays); + const quickRelayUrls = [ + ...searchRelays, // Dedicated profile search relays + ...userInboxRelays, // User's personal inbox relays + ...communityRelays, // Community relays + ...secondaryRelays // Secondary relays as fallback + ]; + + // Deduplicate relay URLs + const uniqueRelayUrls = [...new Set(quickRelayUrls)]; + console.log("Using relays for profile search:", uniqueRelayUrls); // Create relay sets for parallel search - const relaySets = quickRelayUrls + const relaySets = uniqueRelayUrls .map((url) => { try { return NDKRelaySet.fromRelayUrls([url], ndk); @@ -289,7 +298,7 @@ async function quickRelaySearch( let eventCount = 0; console.log( - `Starting search on relay ${index + 1}: ${quickRelayUrls[index]}`, + `Starting search on relay ${index + 1}: ${uniqueRelayUrls[index]}`, ); const sub = ndk.subscribe( @@ -354,7 +363,7 @@ async function quickRelaySearch( sub.on("eose", () => { console.log( - `Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`, + `Relay ${index + 1} (${uniqueRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`, ); resolve(foundInRelay); }); @@ -362,7 +371,7 @@ async function quickRelaySearch( // Short timeout for quick search setTimeout(() => { console.log( - `Relay ${index + 1} (${quickRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`, + `Relay ${index + 1} (${uniqueRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`, ); sub.stop(); resolve(foundInRelay);