clone of repo on github
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1381 lines
54 KiB

<script lang="ts">
import { onMount } from "svelte";
import { Heading, P } from "flowbite-svelte";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { userStore } from "$lib/stores/userStore";
import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte";
import { ndkInstance, activeInboxRelays } from "$lib/ndk";
import { neventEncode } from "$lib/utils";
import { getUserMetadata, NDKRelaySetFromNDK } from "$lib/utils/nostrUtils";
import { goto } from "$app/navigation";
import { get } from "svelte/store";
import { nip19 } from "nostr-tools";
import { communityRelays, localRelays, anonymousRelays, searchRelays } from "$lib/consts";
import { createKind24Reply, getKind24RelaySet } from "$lib/utils/kind24_utils";
import { createSignedEvent } from "$lib/utils/nostrEventService";
import RelayDisplay from "$lib/components/RelayDisplay.svelte";
import RelayInfoList from "$lib/components/RelayInfoList.svelte";
import { Modal, Button } from "flowbite-svelte";
import { searchProfiles } from "$lib/utils/search_utility";
import type { NostrProfile } from "$lib/utils/search_types";
import { PlusOutline, ReplyOutline } from "flowbite-svelte-icons";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
const { event } = $props<{ event: NDKEvent }>();
// Handle navigation events from quoted messages
$effect(() => {
if (typeof window !== 'undefined') {
const handleJumpToMessage = (e: Event) => {
const customEvent = e as CustomEvent;
jumpToMessageInFeed(customEvent.detail);
};
window.addEventListener('jump-to-message', handleJumpToMessage);
return () => {
window.removeEventListener('jump-to-message', handleJumpToMessage);
};
}
});
// Component state
let notifications = $state<NDKEvent[]>([]);
let publicMessages = $state<NDKEvent[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let isOwnProfile = $state(false);
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 filteredByUser = $state<string | null>(null);
// New Message Modal state
let showNewMessageModal = $state(false);
let newMessageContent = $state<string>("");
let selectedRecipients = $state<NostrProfile[]>([]);
let newMessageRelays = $state<string[]>([]);
let isComposingMessage = $state(false);
let replyToMessage = $state<NDKEvent | null>(null);
let quotedContent = $state<string>("");
// Recipient Selection Modal state
let showRecipientModal = $state(false);
let recipientSearch = $state("");
let recipientResults = $state<NostrProfile[]>([]);
let recipientLoading = $state(false);
let recipientSearchInput = $state<HTMLInputElement | undefined>();
let recipientSearchTimeout: ReturnType<typeof setTimeout> | null = null;
let recipientCommunityStatus: Record<string, boolean> = $state({});
let isRecipientSearching = $state(false);
// Derived state for filtered messages
let filteredMessages = $derived.by(() => {
if (!filteredByUser) return publicMessages;
return publicMessages.filter(message =>
message.pubkey === filteredByUser ||
(message.pubkey === $userStore.pubkey && message.getMatchingTags("p").some(tag => tag[1] === filteredByUser))
);
});
// 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 {
const relays = getAvailableRelays();
return neventEncode(event, relays);
}
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) {
// Navigate to the events search page with this specific event
goto(`/events?id=${nevent}`);
}
function jumpToMessageInFeed(eventIdOrNevent: string) {
// Switch to public messages tab and scroll to the specific message
notificationMode = "public-messages";
// Try to find and scroll to the specific message
setTimeout(() => {
let eventId = eventIdOrNevent;
// If it's a nevent URL, try to extract the event ID
if (eventIdOrNevent.startsWith('nostr:nevent') || eventIdOrNevent.startsWith('nevent')) {
try {
const decoded = nip19.decode(eventIdOrNevent);
if (decoded.type === 'nevent' && decoded.data.id) {
eventId = decoded.data.id;
}
} catch (error) {
// If decode fails, try to extract hex ID directly
const hexMatch = eventIdOrNevent.match(/[a-f0-9]{64}/i);
if (hexMatch) {
eventId = hexMatch[0];
} else {
console.warn('Failed to extract event ID from nevent:', eventIdOrNevent);
return;
}
}
}
// Find the message in our public messages
const targetMessage = publicMessages.find(msg => msg.id === eventId);
if (targetMessage) {
// Try to find the element in the DOM
const element = document.querySelector(`[data-event-id="${eventId}"]`);
if (element) {
// Check if element is in viewport
const rect = element.getBoundingClientRect();
const isInView = (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
// Only scroll if not in view
if (!isInView) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// ALWAYS highlight the message in blue
element.classList.add('ring-2', 'ring-blue-500');
setTimeout(() => {
element.classList.remove('ring-2', 'ring-blue-500');
}, 2000);
}
}
}, 100);
}
function filterByUser(pubkey: string) {
filteredByUser = filteredByUser === pubkey ? null : pubkey;
}
function clearFilter() {
filteredByUser = null;
}
// AI-NOTE: New Message Modal Functions
function openNewMessageModal(messageToReplyTo?: NDKEvent) {
showNewMessageModal = true;
newMessageContent = "";
selectedRecipients = [];
newMessageRelays = [];
isComposingMessage = false;
replyToMessage = messageToReplyTo || null;
// 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<string>();
// 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 closeRecipientModal() {
showRecipientModal = false;
recipientSearch = "";
recipientResults = [];
recipientLoading = false;
recipientCommunityStatus = {};
isRecipientSearching = false;
// Clear any pending search timeout
if (recipientSearchTimeout) {
clearTimeout(recipientSearchTimeout);
recipientSearchTimeout = null;
}
}
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 {
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 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();
}
async function sendNewMessage() {
if (!newMessageContent.trim() || selectedRecipients.length === 0) return;
try {
isComposingMessage = true;
// Create p-tags for all recipients (ensure hex format)
const pTags = selectedRecipients.map(recipient => {
let pubkey = recipient.pubkey!;
// Convert npub to hex if needed
if (pubkey.startsWith('npub')) {
try {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
pubkey = decoded.data;
}
} catch (e) {
console.warn("[Send Message] Failed to decode npub:", pubkey, e);
}
}
return ["p", pubkey];
});
// Add q tag if replying to a message (for jump-to functionality)
if (replyToMessage) {
pTags.push(["q", replyToMessage.id, newMessageRelays[0] || anonymousRelays[0]]);
}
// Get all recipient pubkeys for relay calculation (ensure hex format)
const recipientPubkeys = selectedRecipients.map(r => {
let pubkey = r.pubkey!;
// Convert npub to hex if needed
if (pubkey.startsWith('npub')) {
try {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
pubkey = decoded.data;
}
} catch (e) {
console.warn("[Send Message Relay Calc] Failed to decode npub:", pubkey, e);
}
}
return pubkey;
});
// Calculate relay set using the same logic as kind24_utils
const senderPubkey = $userStore.pubkey;
if (!senderPubkey) {
throw new Error("No sender pubkey available");
}
// 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;
// Use the content as-is, quoted content is handled via q tag
const finalContent = newMessageContent;
// Create and sign the event using the unified function (includes expiration tag)
const { event: signedEvent } = await createSignedEvent(
finalContent,
$userStore.pubkey || '',
24,
pTags
);
// 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<void>((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 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<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
async function fetchNotifications() {
if (!$userStore.pubkey || !isOwnProfile) return;
loading = true;
error = null;
try {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("No NDK instance available");
const relays = getAvailableRelays();
if (relays.length === 0) throw new Error("No relays available");
const filter = {
kinds: [1, 1111, 9802, 6, 16],
...(notificationMode === "to-me"
? { "#p": [$userStore.pubkey] }
: { authors: [$userStore.pubkey] }
),
limit: 100,
};
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk);
const events = await ndk.fetchEvents(filter, undefined, relaySet);
const eventArray = Array.from(events);
// Filter out self-referential events
const filteredEvents = eventArray.filter(event => {
if (notificationMode === "to-me") {
return event.pubkey !== $userStore.pubkey;
} else {
const pTags = event.getMatchingTags("p");
const isUserMentioned = pTags.some(tag => tag[1] === $userStore.pubkey);
return !isUserMentioned;
}
});
notifications = filteredEvents
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
.slice(0, 100);
await fetchAuthorProfiles(notifications);
} catch (err) {
console.error("[Notifications] Error fetching notifications:", err);
error = err instanceof Error ? err.message : "Failed to fetch notifications";
} finally {
loading = false;
}
}
// AI-NOTE: Simplified public messages fetching - only kind 24 messages
async function fetchPublicMessages() {
if (!$userStore.pubkey || !isOwnProfile) return;
loading = true;
error = null;
try {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("No NDK instance available");
const relays = getAvailableRelays();
if (relays.length === 0) throw new Error("No relays available");
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk);
// Fetch only kind 24 messages
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], authors: [$userStore.pubkey], limit: 200 }, undefined, relaySet)
]);
const allMessages = [
...Array.from(messagesEvents),
...Array.from(userMessagesEvents)
];
// Deduplicate and filter
const uniqueMessages = allMessages.filter((event, index, self) =>
index === self.findIndex(e => e.id === event.id)
);
publicMessages = uniqueMessages
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
.slice(0, 200);
await fetchAuthorProfiles(publicMessages);
} catch (err) {
console.error("[PublicMessages] Error fetching public messages:", err);
error = err instanceof Error ? err.message : "Failed to fetch public messages";
} finally {
loading = false;
}
}
// Check if user is viewing their own profile
$effect(() => {
if ($userStore.signedIn && $userStore.pubkey && event.pubkey) {
isOwnProfile = $userStore.pubkey.toLowerCase() === event.pubkey.toLowerCase();
} else {
isOwnProfile = false;
}
});
// Fetch notifications when viewing own profile or when mode changes
$effect(() => {
if (isOwnProfile && $userStore.pubkey && $userStore.signedIn) {
if (notificationMode === "public-messages") {
fetchPublicMessages();
} else {
fetchNotifications();
}
} else {
// Clear notifications when user logs out or is not viewing own profile
notifications = [];
publicMessages = [];
authorProfiles.clear();
}
});
// AI-NOTE: Refactored to avoid blocking $effect with async operations
// Calculate relay set when recipients change - non-blocking approach
$effect(() => {
const senderPubkey = $userStore.pubkey;
console.log("[Relay Effect] Recipients changed:", selectedRecipients.length, "Sender:", senderPubkey?.slice(0, 8));
if (selectedRecipients.length > 0 && senderPubkey) {
// Start async relay set calculation without blocking the effect
updateRelaySet(selectedRecipients, senderPubkey);
} else {
console.log("[Relay Effect] Clearing relays - no recipients or sender");
newMessageRelays = [];
}
});
/**
* Updates relay set asynchronously to avoid blocking the reactive system
*/
async function updateRelaySet(recipients: any[], senderPubkey: string) {
try {
const recipientPubkeys = recipients.map(r => {
const pubkey = r.pubkey!;
// Convert npub to hex if needed
if (pubkey.startsWith('npub')) {
try {
const decoded = nip19.decode(pubkey) as unknown as { type: string; data: string };
if (decoded.type === 'npub') {
return decoded.data;
}
} catch (e) {
console.warn("[Relay Effect] Failed to decode npub:", pubkey, e);
}
}
return pubkey;
});
console.log("[Relay Effect] Getting relay sets for recipients (hex):", recipientPubkeys.map(p => p.slice(0, 8)));
// Get relay sets for all recipients and combine them
const relaySetPromises = recipientPubkeys.map(recipientPubkey =>
getKind24RelaySet(senderPubkey, recipientPubkey)
);
const relaySets = await Promise.all(relaySetPromises);
console.log("[Relay Effect] Received relay sets:", relaySets);
// Combine and deduplicate all relay sets
const allRelays = relaySets.flat();
const uniqueRelays = [...new Set(allRelays)];
console.log("[Relay Effect] Final relay list:", uniqueRelays);
// If no relays found from NIP-65, use fallback relays
if (uniqueRelays.length === 0) {
console.log("[Relay Effect] No NIP-65 relays found, using fallback");
const fallbackRelays = getAvailableRelays();
newMessageRelays = fallbackRelays.slice(0, 5); // Limit to first 5 for performance
} else {
newMessageRelays = uniqueRelays;
}
} catch (error) {
console.error("[Relay Effect] Error getting relay set:", error);
console.log("[Relay Effect] Using fallback relays due to error");
const fallbackRelays = getAvailableRelays();
newMessageRelays = fallbackRelays.slice(0, 5);
}
}
</script>
{#if isOwnProfile && $userStore.signedIn}
<div class="mb-6">
<div class="flex items-center justify-between mb-4">
<Heading tag="h3" class="h-leather">Notifications</Heading>
<div class="flex items-center gap-3">
<!-- New Message Button -->
<Button
color="primary"
size="sm"
onclick={() => openNewMessageModal()}
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium"
>
<PlusOutline class="w-4 h-4" />
New Message
</Button>
<!-- Mode toggle -->
<div class="flex bg-gray-300 dark:bg-gray-700 rounded-lg p-1">
{#each ["to-me", "from-me", "public-messages"] as mode}
{@const modeLabel = mode === "to-me" ? "To Me" : mode === "from-me" ? "From Me" : "Public Messages"}
<button
class="px-3 py-1 text-sm font-medium rounded-md transition-colors {notificationMode === mode ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-700 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'}"
onclick={() => notificationMode = mode as "to-me" | "from-me" | "public-messages"}
>
{modeLabel}
</button>
{/each}
</div>
</div>
</div>
{#if loading}
<div class="flex items-center justify-center py-8 min-h-96">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<span class="ml-2 text-gray-600 dark:text-gray-400">
Loading {notificationMode === "public-messages" ? "public messages" : "notifications"}...
</span>
</div>
{:else if error}
<div class="p-4 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 rounded-lg">
<P>Error loading {notificationMode === "public-messages" ? "public messages" : "notifications"}: {error}</P>
</div>
{:else if notificationMode === "public-messages"}
{#if publicMessages.length === 0}
<div class="p-4 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-lg">
<P>No public messages found.</P>
</div>
{:else}
<div class="max-h-[72rem] overflow-y-auto">
{#if filteredByUser}
<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">
<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)}`}
</span>
<button
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline font-medium"
onclick={clearFilter}
>
Clear Filter
</button>
</div>
</div>
{/if}
<div class="space-y-4">
{#each filteredMessages.slice(0, 100) as message}
{@const authorProfile = authorProfiles.get(message.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="flex items-start gap-3 {isFromUser ? 'flex-row-reverse' : ''}">
<!-- Author Profile Picture -->
<div class="flex-shrink-0 relative">
{#if authorProfile?.picture}
<img
src={authorProfile.picture}
alt="Author avatar"
class="w-10 h-10 rounded-full object-cover border border-gray-200 dark:border-gray-600"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
{:else}
<div class="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{(authorProfile?.displayName || authorProfile?.name || message.pubkey.slice(0, 1)).toUpperCase()}
</span>
</div>
{/if}
<!-- Filter button for non-user messages -->
{#if !isFromUser}
<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={(e) => {
e.preventDefault();
e.stopPropagation();
openNewMessageModal(message);
}}
title="Reply to this message"
aria-label="Reply to this message"
>
<ReplyOutline class="w-3 h-3" />
</button>
<!-- Filter 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' : ''}"
onclick={() => filterByUser(message.pubkey)}
title="Filter by this user"
aria-label="Filter by this user"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 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 6V3z" clip-rule="evenodd" />
</svg>
</button>
</div>
{/if}
</div>
<!-- Message Content -->
<div class="flex-1 min-w-0 {isFromUser ? 'text-right' : ''}">
<div class="flex items-center gap-2 mb-2 {isFromUser ? 'justify-end' : ''}">
<span class="text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-100 dark:bg-primary-900 px-2 py-1 rounded">
{isFromUser ? 'Your Message' : 'Public Message'}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{message.created_at ? formatDate(message.created_at) : "Unknown date"}
</span>
<button
class="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-200 underline font-mono"
onclick={() => navigateToEvent(getNeventUrl(message))}
title="Click to view event"
>
{getNeventUrl(message).slice(0, 16)}...
</button>
</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}
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
{#await renderQuotedContent(message) then quotedHtml}
{@html quotedHtml}
{:catch}
<!-- Fallback if quoted content fails to render -->
{/await}
</div>
{/if}
{#if message.content}
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
{#await parseContent(message.content) then parsedContent}
{@html parsedContent}
{:catch}
{@html message.content}
{/await}
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
{#if filteredMessages.length > 100}
<div class="mt-4 p-3 text-center text-sm text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg">
Showing 100 of {filteredMessages.length} messages {filteredByUser ? `(filtered)` : ''}. Scroll to see more.
</div>
{/if}
</div>
{/if}
{:else}
{#if notifications.length === 0}
<div class="p-4 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-lg">
<P>No notifications {notificationMode === "to-me" ? "received" : "sent"} found.</P>
</div>
{:else}
<div class="max-h-[72rem] overflow-y-auto space-y-4">
{#each notifications.slice(0, 100) as notification}
{@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="flex items-start gap-3">
<!-- Author Profile Picture -->
<div class="flex-shrink-0">
{#if authorProfile?.picture}
<img
src={authorProfile.picture}
alt="Author avatar"
class="w-10 h-10 rounded-full object-cover border border-gray-200 dark:border-gray-600"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
{:else}
<div class="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{(authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 1)).toUpperCase()}
</span>
</div>
{/if}
</div>
<!-- Notification Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-100 dark:bg-primary-900 px-2 py-1 rounded">
{getNotificationType(notification)}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{notification.created_at ? formatDate(notification.created_at) : "Unknown date"}
</span>
<button
class="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-200 underline font-mono"
onclick={() => navigateToEvent(getNeventUrl(notification))}
title="Click to view event"
>
{getNeventUrl(notification).slice(0, 16)}...
</button>
</div>
<!-- Author Name -->
<div class="mb-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{authorProfile?.displayName || authorProfile?.name || `${notification.pubkey.slice(0, 8)}...${notification.pubkey.slice(-4)}`}
</span>
{#if authorProfile?.name && authorProfile?.displayName && authorProfile.name !== authorProfile.displayName}
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">
(@{authorProfile.name})
</span>
{/if}
</div>
{#if notification.content}
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
{truncateContent(notification.content)}
</div>
{/if}
</div>
</div>
</div>
{/each}
{#if notifications.length > 100}
<div class="mt-4 p-3 text-center text-sm text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg">
Showing 100 of {notifications.length} notifications {notificationMode === "to-me" ? "received" : "sent"}. Scroll to see more.
</div>
{/if}
</div>
{/if}
{/if}
</div>
<!-- New Message Modal -->
<Modal bind:open={showNewMessageModal} size="lg" class="w-full">
<div class="p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{replyToMessage ? 'Reply to Message' : 'New Public Message'}
</h3>
</div>
<!-- Quoted Content Display -->
{#if quotedContent}
<div class="mb-4 p-3 bg-gray-100 dark:bg-gray-800 border-l-4 border-gray-400 dark:border-gray-500 rounded-r-lg">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Replying to:</div>
<div class="text-sm text-gray-800 dark:text-gray-200">
{#await parseContent(quotedContent) then parsedContent}
{@html parsedContent}
{:catch}
{@html quotedContent}
{/await}
</div>
</div>
{/if}
<!-- Recipients Section -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Sending to {selectedRecipients.length} recipient{selectedRecipients.length !== 1 ? 's' : ''}:
</span>
<button
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
openRecipientModal();
}}
>
Edit Recipients
</button>
</div>
{#if selectedRecipients.length === 0}
<div class="p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg">
<p class="text-sm text-yellow-700 dark:text-yellow-300">
No recipients selected. Click "Edit Recipients" to add recipients.
</p>
</div>
{:else}
<div class="flex flex-wrap gap-2">
{#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">
{recipient.displayName || recipient.name || `${recipient.pubkey?.slice(0, 8)}...`}
<button
onclick={() => {
selectedRecipients = selectedRecipients.filter(r => r.pubkey !== recipient.pubkey);
}}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
×
</button>
</span>
{/each}
</div>
{/if}
</div>
<!-- Relay Information -->
{#if selectedRecipients.length > 0 && newMessageRelays.length > 0}
<div class="mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
Publishing to {newMessageRelays.length} relay{newMessageRelays.length !== 1 ? 's' : ''}:
</span>
<div class="p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<div class="space-y-1">
{#each newMessageRelays as relay}
<div class="text-xs font-mono text-gray-600 dark:text-gray-400">
{relay}
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Message Content -->
<div class="mb-4">
<label for="new-message-content" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
Message:
</label>
<textarea
id="new-message-content"
bind:value={newMessageContent}
placeholder="Type your message here..."
class="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows="6"
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !isComposingMessage && selectedRecipients.length > 0 && newMessageContent.trim()) {
e.preventDefault();
sendNewMessage();
}
}}
></textarea>
</div>
<!-- Action Buttons -->
<div class="flex justify-end gap-3">
<Button
color="light"
onclick={closeNewMessageModal}
disabled={isComposingMessage}
>
Cancel
</Button>
<Button
color="primary"
onclick={sendNewMessage}
disabled={isComposingMessage || selectedRecipients.length === 0 || !newMessageContent.trim()}
class="flex items-center gap-2"
>
{#if isComposingMessage}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{/if}
Send to {selectedRecipients.length} recipient{selectedRecipients.length !== 1 ? 's' : ''}
</Button>
</div>
</div>
</Modal>
<!-- Recipient Selection Modal -->
<Modal bind:open={showRecipientModal} size="lg" class="w-full">
<div class="p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Select Recipients</h3>
</div>
<div class="space-y-4">
<div class="relative">
<input
type="text"
placeholder="Search display name, name, NIP-05, or npub..."
bind:value={recipientSearch}
bind:this={recipientSearchInput}
class="w-full rounded-lg border border-gray-300 bg-gray-50 text-gray-900 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500 p-2.5 {recipientLoading ? 'pr-10' : ''}"
/>
{#if recipientLoading}
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
</div>
{/if}
</div>
{#if recipientResults.length > 0}
<div class="max-h-64 overflow-y-auto">
<ul class="space-y-2">
{#each recipientResults as profile}
{@const isAlreadySelected = selectedRecipients.some(r => r.pubkey === profile.pubkey)}
<button
onclick={() => selectRecipient(profile)}
disabled={isAlreadySelected}
class="w-full flex items-center gap-3 p-3 text-left bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors {isAlreadySelected ? 'opacity-50 cursor-not-allowed' : ''}"
>
{#if profile.picture}
<img
src={profile.picture}
alt="Profile"
class="w-8 h-8 rounded-full object-cover border border-gray-200 dark:border-gray-600 flex-shrink-0"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
<div
class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0"
></div>
{/if}
<div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate">
{profile.displayName || profile.name || recipientSearch}
</span>
{#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1">
<svg
class="inline w-4 h-4 text-primary-500"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
></path></svg
>
{profile.nip05}
</span>
{/if}
{#if profile.about}
<span class="text-xs text-gray-500 truncate">{profile.about}</span>
{/if}
</div>
{#if recipientCommunityStatus[profile.pubkey || ""]}
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
{#if isAlreadySelected}
<span class="text-xs text-green-600 dark:text-green-400 font-medium">Selected</span>
{/if}
</button>
{/each}
</ul>
</div>
{:else if recipientSearch.trim()}
<div class="text-center py-4 text-gray-500">No results found</div>
{:else}
<div class="text-center py-4 text-gray-500">
Enter a search term to find users
</div>
{/if}
</div>
</div>
</Modal>
{/if}