diff --git a/package-lock.json b/package-lock.json index 96876ed..1a886ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "14.0", + "version": "14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "14.0", + "version": "14.1", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 4e465c9..95b791a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jumble-imwald", - "version": "14.0", + "version": "14.1", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "private": true, "type": "module", diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 7366d68..ed1bf68 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -471,6 +471,19 @@ export function useMenuActions({ } ] + // Add "View on Alexandria" menu item for public messages (PMs) + if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { + actions.push({ + icon: Globe, + label: t('View on Alexandria'), + onClick: () => { + closeDrawer() + window.open('https://next-alexandria.gitcitadel.eu/profile/notifications', '_blank', 'noopener,noreferrer') + }, + separator: true + }) + } + // Add "Create Highlight" action for OP events if (isOPEvent && openHighlightEditor) { actions.push({ diff --git a/src/components/NotificationList/NotificationItem/Notification.tsx b/src/components/NotificationList/NotificationItem/Notification.tsx index 245a331..75039e2 100644 --- a/src/components/NotificationList/NotificationItem/Notification.tsx +++ b/src/components/NotificationList/NotificationItem/Notification.tsx @@ -24,7 +24,8 @@ export default function Notification({ middle = null, targetEvent, isNew = false, - showStats = false + showStats = false, + rightAction = null }: { icon: React.ReactNode notificationId: string @@ -35,6 +36,7 @@ export default function Notification({ targetEvent?: NostrEvent isNew?: boolean showStats?: boolean + rightAction?: React.ReactNode }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigation() @@ -122,16 +124,19 @@ export default function Notification({ />
{description}
- {unread && ( - diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 5a5601b..f6e920d 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -123,6 +123,7 @@ export default function PostRelaySelector({ parentEvent: _parentEvent, isPublicMessage, content: isDiscussionReply ? '' : postContent, // Don't use content for discussion replies + mentions: isPublicMessage ? mentions : undefined, // Pass mentions for PMs userPubkey: pubkey || undefined, openFrom: memoizedOpenFrom }) @@ -161,7 +162,7 @@ export default function PostRelaySelector({ } updateRelaySelection() - }, [memoizedOpenFrom, _parentEvent, memoizedFavoriteRelays, memoizedBlockedRelays, memoizedRelaySets, isPublicMessage, pubkey, relayList, isDiscussionReply]) + }, [memoizedOpenFrom, _parentEvent, memoizedFavoriteRelays, memoizedBlockedRelays, memoizedRelaySets, isPublicMessage, pubkey, relayList, isDiscussionReply, postContent, mentions]) // Separate effect for mention changes in non-discussion replies useEffect(() => { @@ -213,7 +214,8 @@ export default function PostRelaySelector({ relaySets: memoizedRelaySets, parentEvent: _parentEvent, isPublicMessage, - content: postContent, + content: isDiscussionReply ? '' : postContent, // Don't use content for discussion replies + mentions: isPublicMessage ? mentions : undefined, // Pass mentions for PMs userPubkey: pubkey || undefined, openFrom: memoizedOpenFrom }) @@ -311,22 +313,33 @@ export default function PostRelaySelector({
{t('No relays available')}
) : (
- {selectableRelays.map((url) => { - const isChecked = selectedRelayUrls.includes(url) - return ( -
handleRelayCheckedChange(!isChecked, url)} - > -
- {isChecked && } + {(() => { + // Sort relays so selected ones appear at the top + const sortedRelays = [...selectableRelays].sort((a, b) => { + const aSelected = selectedRelayUrls.includes(a) + const bSelected = selectedRelayUrls.includes(b) + if (aSelected && !bSelected) return -1 + if (!aSelected && bSelected) return 1 + return 0 + }) + + return sortedRelays.map((url) => { + const isChecked = selectedRelayUrls.includes(url) + return ( +
handleRelayCheckedChange(!isChecked, url)} + > +
+ {isChecked && } +
+ + {simplifyUrl(url)}
- - {simplifyUrl(url)} -
- ) - })} + ) + }) + })()}
)} diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index bcc3b04..f388818 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -301,6 +301,7 @@ export async function createPublicMessageReplyDraftEvent( expirationMonths?: number addQuietTag?: boolean quietDays?: number + mediaImetaTags?: string[][] // Allow media imeta tags for audio/video } = {} ): Promise { // Process content to prefix nostr addresses before other transformations @@ -317,6 +318,11 @@ export async function createPublicMessageReplyDraftEvent( .concat(quoteEventHexIds.map((eventId) => buildQTag(eventId))) .concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate))) + // Add media imeta tags if provided (for audio/video) + if (options.mediaImetaTags && options.mediaImetaTags.length > 0) { + tags.push(...options.mediaImetaTags) + } + const images = extractImagesFromContent(transformedEmojisContent) if (images && images.length) { tags.push(...generateImetaTags(images)) @@ -383,6 +389,7 @@ export async function createPublicMessageDraftEvent( expirationMonths?: number addQuietTag?: boolean quietDays?: number + mediaImetaTags?: string[][] // Allow media imeta tags for audio/video } = {} ): Promise { // Process content to prefix nostr addresses before other transformations @@ -393,6 +400,11 @@ export async function createPublicMessageDraftEvent( const tags = emojiTags .concat(hashtags.map((hashtag) => buildTTag(hashtag))) + // Add media imeta tags if provided (for audio/video) + if (options.mediaImetaTags && options.mediaImetaTags.length > 0) { + tags.push(...options.mediaImetaTags) + } + const images = extractImagesFromContent(transformedEmojisContent) if (images && images.length) { tags.push(...generateImetaTags(images)) diff --git a/src/services/relay-selection.service.ts b/src/services/relay-selection.service.ts index 5588bc2..380c6fe 100644 --- a/src/services/relay-selection.service.ts +++ b/src/services/relay-selection.service.ts @@ -20,6 +20,7 @@ export interface RelaySelectionContext { parentEvent?: Event isPublicMessage?: boolean content?: string + mentions?: string[] // Pre-extracted mentions (for PMs) userPubkey?: string openFrom?: string[] } @@ -385,77 +386,175 @@ class RelaySelectionService { /** * Get relays for public messages: sender outboxes + receiver inboxes + * Only includes outboxes from sender and inboxes from all recipients + * Normalized and deduplicated. If more than 10, limits to one per member, + * preferring relays that multiple people have. */ private async getPublicMessageRelays(context: RelaySelectionContext): Promise { - const { userWriteRelays, parentEvent, isPublicMessage, content, userPubkey } = context - const relays = new Set() + const { userWriteRelays, parentEvent, isPublicMessage, content, mentions, userPubkey } = context + + // Map to track which relays belong to which members + const relayToMembers = new Map>() + const allMembers = new Set() try { - // Add sender's write relays (outboxes) - fallback to fast write relays if no user relays - const senderRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS - senderRelays.forEach(url => { - const normalized = normalizeUrl(url) - if (normalized) { - relays.add(normalized) - } else { - relays.add(url) + // Get sender's outboxes (write relays) + if (userPubkey) { + allMembers.add(userPubkey) + let senderRelays = userWriteRelays + + // If userWriteRelays is empty, try to fetch the user's relay list + if (senderRelays.length === 0) { + try { + const userRelayList = await this.getCachedRelayList(userPubkey) + if (userRelayList?.write && userRelayList.write.length > 0) { + senderRelays = userRelayList.write + } else { + // Only fall back to fast write relays if we truly have no user relays + senderRelays = FAST_WRITE_RELAY_URLS + } + } catch (error) { + logger.warn('Failed to fetch user relay list for PM', { error, userPubkey }) + // Fall back to fast write relays if fetch fails + senderRelays = FAST_WRITE_RELAY_URLS + } } - }) - - // Add receiver's read relays (inboxes) - if (isPublicMessage && content && userPubkey) { - // For new public messages, get mentioned users' read relays - const mentions = await this.extractMentions(content, parentEvent) - const mentionedPubkeys = mentions.filter(p => p !== userPubkey) - if (mentionedPubkeys.length > 0) { - const receiverRelayLists = await Promise.all( - mentionedPubkeys.map(async (pubkey) => { - try { - const relayList = await client.fetchRelayList(pubkey) - const userRelays = relayList?.read || [] - // Filter out local relays from other users - return this.filterLocalRelaysFromOthers(userRelays) - } catch (error) { - logger.warn('Failed to fetch relay list', { pubkey, error }) - return [] - } - }) - ) - receiverRelayLists.flat().forEach(url => { + senderRelays.forEach(url => { + const normalized = normalizeUrl(url) + if (normalized) { + if (!relayToMembers.has(normalized)) { + relayToMembers.set(normalized, new Set()) + } + relayToMembers.get(normalized)!.add(userPubkey) + } + }) + } + + // Get recipients and their inboxes (read relays) + let recipientPubkeys: string[] = [] + + if (isPublicMessage && userPubkey) { + // For new public messages, use provided mentions or extract from content + if (mentions && mentions.length > 0) { + recipientPubkeys = mentions.filter(p => p !== userPubkey) + } else if (content) { + // Fallback to extracting from content if mentions not provided + const extractedMentions = await this.extractMentions(content, parentEvent) + recipientPubkeys = extractedMentions.filter(p => p !== userPubkey) + } + } else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { + // For public message replies, get all recipients from parent event + // Include original sender and all p tags + recipientPubkeys = [parentEvent.pubkey] + parentEvent.tags.forEach(([tagName, tagValue]) => { + if (tagName === 'p' && tagValue && tagValue !== userPubkey) { + recipientPubkeys.push(tagValue) + } + }) + // Deduplicate + recipientPubkeys = Array.from(new Set(recipientPubkeys)) + } + + // Fetch read relays (inboxes) for all recipients + if (recipientPubkeys.length > 0) { + const recipientRelayLists = await Promise.all( + recipientPubkeys.map(async (pubkey) => { + try { + allMembers.add(pubkey) + // Use cached version from IndexedDB + const relayList = await this.getCachedRelayList(pubkey) + if (!relayList) return [] + const userRelays = relayList.read || [] + // Filter out local relays from other users + return this.filterLocalRelaysFromOthers(userRelays) + } catch (error) { + logger.warn('Failed to fetch relay list', { pubkey, error }) + return [] + } + }) + ) + + // Track which relays belong to which recipients + recipientRelayLists.forEach((relays, index) => { + const pubkey = recipientPubkeys[index] + relays.forEach(url => { const normalized = normalizeUrl(url) if (normalized) { - relays.add(normalized) - } else { - relays.add(url) + if (!relayToMembers.has(normalized)) { + relayToMembers.set(normalized, new Set()) + } + relayToMembers.get(normalized)!.add(pubkey) + } + }) + }) + } + + // Build final relay list + const relays: string[] = [] + + // If we have 10 or fewer relays, use all of them + if (relayToMembers.size <= 10) { + relays.push(...Array.from(relayToMembers.keys())) + } else { + // More than 10 relays - need to limit to one per member + // Prefer relays that multiple people have + + // Sort relays by number of members (descending), then by URL for stability + const sortedRelays = Array.from(relayToMembers.entries()) + .sort((a, b) => { + const aCount = a[1].size + const bCount = b[1].size + if (aCount !== bCount) { + return bCount - aCount // Prefer relays with more members } + return a[0].localeCompare(b[0]) // Stable sort by URL }) + + // Track which members already have a relay selected + const selectedForMember = new Map() + + // First pass: assign relays that multiple people have + for (const [relayUrl, members] of sortedRelays) { + if (members.size > 1) { + // This relay is used by multiple people - add it + relays.push(relayUrl) + // Mark all members as having a relay + members.forEach(member => { + selectedForMember.set(member, relayUrl) + }) + } } - } else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { - // For public message replies, get original sender's read relays (filter out their local relays) - // Use cached version from IndexedDB instead of fetching from relays - try { - const senderRelayList = await this.getCachedRelayList(parentEvent.pubkey) - if (senderRelayList?.read) { - const filteredRelays = this.filterLocalRelaysFromOthers(senderRelayList.read) - filteredRelays.forEach(url => { - const normalized = normalizeUrl(url) - if (normalized) { - relays.add(normalized) - } else { - relays.add(url) + + // Second pass: ensure each member has at least one relay + for (const [relayUrl, members] of sortedRelays) { + if (relays.length >= 10) break + + // Check if any member still needs a relay + const needsRelay = Array.from(members).some(member => !selectedForMember.has(member)) + if (needsRelay) { + relays.push(relayUrl) + members.forEach(member => { + if (!selectedForMember.has(member)) { + selectedForMember.set(member, relayUrl) } }) } - } catch (error) { - logger.warn('Failed to fetch relay list for parent event', { parentPubkey: parentEvent.pubkey, error }) } } + + // Normalize and deduplicate final list + const normalizedRelays = relays + .map(url => normalizeUrl(url)) + .filter((url): url is string => !!url) + + return Array.from(new Set(normalizedRelays)) } catch (error) { logger.error('Failed to get public message relays', { error, parentEvent: context.parentEvent?.id }) + // Fallback to sender's write relays + const senderRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS + return senderRelays.map(url => normalizeUrl(url) || url).filter(Boolean) } - - return Array.from(relays) }