Browse Source

fix public messages and allow audio messaging

imwald
Silberengel 4 months ago
parent
commit
4a0cdd641e
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 13
      src/components/NoteOptions/useMenuActions.tsx
  4. 7
      src/components/NotificationList/NotificationItem/Notification.tsx
  5. 119
      src/components/PostEditor/PostContent.tsx
  6. 21
      src/components/PostEditor/PostRelaySelector.tsx
  7. 12
      src/lib/draft-event.ts
  8. 173
      src/services/relay-selection.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -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",

2
package.json

@ -1,6 +1,6 @@ @@ -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",

13
src/components/NoteOptions/useMenuActions.tsx

@ -471,6 +471,19 @@ export function useMenuActions({ @@ -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({

7
src/components/NotificationList/NotificationItem/Notification.tsx

@ -24,7 +24,8 @@ export default function Notification({ @@ -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({ @@ -35,6 +36,7 @@ export default function Notification({
targetEvent?: NostrEvent
isNew?: boolean
showStats?: boolean
rightAction?: React.ReactNode
}) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation()
@ -122,6 +124,8 @@ export default function Notification({ @@ -122,6 +124,8 @@ export default function Notification({
/>
<div className="shrink-0 text-muted-foreground text-sm">{description}</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{rightAction}
{unread && (
<button
className="m-0.5 size-3 bg-primary rounded-full shrink-0 transition-all hover:ring-4 hover:ring-primary/20"
@ -133,6 +137,7 @@ export default function Notification({ @@ -133,6 +137,7 @@ export default function Notification({
/>
)}
</div>
</div>
{middle}
{targetEvent && (
<ContentPreview

119
src/components/PostEditor/PostContent.tsx

@ -262,6 +262,13 @@ export default function PostContent({ @@ -262,6 +262,13 @@ export default function PostContent({
// Helper function to determine the kind that will be created
const getDeterminedKind = useMemo((): number => {
// Public messages always take priority - even with media, they stay as PMs
if (isPublicMessage) {
return ExtendedKind.PUBLIC_MESSAGE
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
return ExtendedKind.PUBLIC_MESSAGE
}
// For voice comments in replies, check mediaNoteKind even if mediaUrl is not set yet (for preview)
// Debug logging
console.log('🔍 getDeterminedKind: checking', {
@ -293,12 +300,8 @@ export default function PostContent({ @@ -293,12 +300,8 @@ export default function PostContent({
return ExtendedKind.CITATION_PROMPT
} else if (isHighlight) {
return kinds.Highlights
} else if (isPublicMessage) {
return ExtendedKind.PUBLIC_MESSAGE
} else if (isPoll) {
return ExtendedKind.POLL
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
return ExtendedKind.PUBLIC_MESSAGE
} else if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) {
console.log('⚠ getDeterminedKind: falling through to COMMENT', {
parentEvent: !!parentEvent,
@ -349,7 +352,31 @@ export default function PostContent({ @@ -349,7 +352,31 @@ export default function PostContent({
shouldUseProtectedEvent = isParentOP && parentHasProtectedTag
}
// Check for voice comments first
// Public messages - check BEFORE media notes to ensure PMs with media stay as PMs
if (isPublicMessage) {
return await createPublicMessageDraftEvent(cleanedText, extractedMentions, {
addClientTag,
isNsfw,
addExpirationTag: false,
expirationMonths,
addQuietTag,
quietDays,
mediaImetaTags: mediaNoteKind !== null && mediaUrl ? mediaImetaTags : undefined
})
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
// For PM replies, always create PM even if there's media
return await createPublicMessageReplyDraftEvent(cleanedText, parentEvent, mentions, {
addClientTag,
isNsfw,
addExpirationTag: false,
expirationMonths,
addQuietTag,
quietDays,
mediaImetaTags: mediaNoteKind !== null && mediaUrl ? mediaImetaTags : undefined
})
}
// Check for voice comments (only for non-PM replies)
if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) {
const url = mediaUrl || 'placeholder://audio'
const tags = mediaImetaTags.length > 0 ? mediaImetaTags : [['imeta', `url ${url}`, 'm audio/mpeg']]
@ -527,26 +554,6 @@ export default function PostContent({ @@ -527,26 +554,6 @@ export default function PostContent({
)
}
// Public messages
if (isPublicMessage) {
return await createPublicMessageDraftEvent(cleanedText, extractedMentions, {
addClientTag,
isNsfw,
addExpirationTag: false,
expirationMonths,
addQuietTag,
quietDays
})
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
return await createPublicMessageReplyDraftEvent(cleanedText, parentEvent, mentions, {
addClientTag,
isNsfw,
addExpirationTag: false,
expirationMonths,
addQuietTag,
quietDays
})
}
// Comments and replies
if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) {
@ -862,14 +869,14 @@ export default function PostContent({ @@ -862,14 +869,14 @@ export default function PostContent({
if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type.startsWith('video/')) {
uploadedMediaFileMap.current.set(file.name, file)
// For replies, if it's an audio file, set mediaNoteKind immediately for preview
if (parentEvent) {
// For replies and PMs, if it's an audio file, set mediaNoteKind immediately for preview
if (parentEvent || isPublicMessage) {
const fileType = file.type
const fileName = file.name.toLowerCase()
// Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4
const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg'
const isAudioExt = /\.(mp3|m4a|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName)
// For replies, webm/ogg/mp3/m4a files should be treated as audio since the microphone button only accepts audio/*
// For replies/PMs, webm/ogg/mp3/m4a files should be treated as audio since the microphone button only accepts audio/*
// Even if the MIME type is incorrect, if it came through the audio uploader, it's audio
const isWebmFile = /\.webm$/i.test(fileName)
const isOggFile = /\.ogg$/i.test(fileName)
@ -878,7 +885,7 @@ export default function PostContent({ @@ -878,7 +885,7 @@ export default function PostContent({
const isM4aFile = /\.m4a$/i.test(fileName)
const isMp4Audio = /\.mp4$/i.test(fileName) && isAudioMime
// For replies, treat webm/ogg/mp3/m4a as audio (since accept="audio/*" should filter out video files)
// For replies/PMs, treat webm/ogg/mp3/m4a as audio (since accept="audio/*" should filter out video files)
// m4a files are always audio, even if MIME type is wrong
const isAudio = isAudioMime || isAudioExt || isM4aFile || isMp4Audio || isWebmFile || isOggFile || isMp3File
@ -895,15 +902,28 @@ export default function PostContent({ @@ -895,15 +902,28 @@ export default function PostContent({
})
if (isAudio) {
// For PM replies, don't set mediaNoteKind - let PM reply handle it with imeta tags
if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
console.log('✅ handleUploadStart: PM reply with audio - will use imeta tags, not setting mediaNoteKind')
// Don't set mediaNoteKind - PM replies stay as kind 24 with imeta tags
} else if (parentEvent) {
console.log('✅ handleUploadStart: setting VOICE_COMMENT for reply', {
mediaNoteKind: ExtendedKind.VOICE_COMMENT,
fileType,
fileName
})
setMediaNoteKind(ExtendedKind.VOICE_COMMENT)
} else if (isPublicMessage) {
console.log('✅ handleUploadStart: setting VOICE for PM', {
mediaNoteKind: ExtendedKind.VOICE,
fileType,
fileName
})
setMediaNoteKind(ExtendedKind.VOICE)
}
// Note: URL will be inserted when upload completes in handleMediaUploadSuccess
} else {
console.log('❌ handleUploadStart: file is not audio, not setting VOICE_COMMENT')
console.log('❌ handleUploadStart: file is not audio, not setting media note kind')
}
} else {
// For new posts, detect the kind from the file (async)
@ -1142,14 +1162,15 @@ export default function PostContent({ @@ -1142,14 +1162,15 @@ export default function PostContent({
// Determine media kind from file
// For replies, only audio comments are supported (kind 1244)
// For new PMs, audio messages are supported (kind 1222)
// For new posts, all media types are supported
if (parentEvent) {
// For replies, only allow audio comments
if (parentEvent || isPublicMessage) {
// For replies and PMs, only allow audio
const fileType = uploadingFile.type
const fileName = uploadingFile.name.toLowerCase()
// Check for audio files - including mp4/m4a/webm/ogg/mp3 which can be audio
// mp4/m4a/webm/ogg/mp3 files can be audio if MIME type is audio/*
// For replies, webm/ogg/mp3 files should be treated as audio since the microphone button only accepts audio/*
// For replies/PMs, webm/ogg/mp3 files should be treated as audio since the microphone button only accepts audio/*
// Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4
const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg'
const isAudioExt = /\.(mp3|m4a|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName)
@ -1160,7 +1181,7 @@ export default function PostContent({ @@ -1160,7 +1181,7 @@ export default function PostContent({
const isOggFile = /\.ogg$/i.test(fileName)
const isMp3File = /\.mp3$/i.test(fileName)
// For replies, treat webm/ogg/mp3/m4a as audio (since accept="audio/*" should filter out video files)
// For replies/PMs, treat webm/ogg/mp3/m4a as audio (since accept="audio/*" should filter out video files)
// m4a files are always audio, even if MIME type is wrong
const isAudio = isAudioMime || isAudioExt || isM4aFile || isMp4Audio || isWebmFile || isOggFile || isMp3File
@ -1177,12 +1198,26 @@ export default function PostContent({ @@ -1177,12 +1198,26 @@ export default function PostContent({
})
if (isAudio) {
// For replies, always create voice comments (kind 1244), regardless of duration
// For PM replies, don't set mediaNoteKind - let PM reply handle it with imeta tags
if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
console.log('✅ handleMediaUploadSuccess: PM reply with audio - will use imeta tags, not setting mediaNoteKind')
// Don't set mediaNoteKind - PM replies stay as kind 24 with imeta tags
// Just set the URL and imeta tags
} else if (parentEvent) {
// For regular replies, always create voice comments (kind 1244), regardless of duration
console.log('✅ handleMediaUploadSuccess: setting VOICE_COMMENT for reply', {
mediaNoteKind: ExtendedKind.VOICE_COMMENT,
url
})
setMediaNoteKind(ExtendedKind.VOICE_COMMENT)
} else if (isPublicMessage) {
// For new PMs, create voice notes (kind 1222)
console.log('✅ handleMediaUploadSuccess: setting VOICE for PM', {
mediaNoteKind: ExtendedKind.VOICE,
url
})
setMediaNoteKind(ExtendedKind.VOICE)
}
setMediaUrl(url)
// Get imeta tag from media upload service
const imetaTag = mediaUpload.getImetaTagByUrl(url)
@ -1195,7 +1230,6 @@ export default function PostContent({ @@ -1195,7 +1230,6 @@ export default function PostContent({
// For webm/ogg/mp3/m4a files uploaded via microphone, ensure MIME type is set to audio/*
// even if the browser reports video/webm or video/mp4 (mobile browsers sometimes do this)
let mimeType = uploadingFile.type
if (parentEvent) {
const fileName = uploadingFile.name.toLowerCase()
if (/\.m4a$/i.test(fileName)) {
// m4a files are always audio, use audio/mp4 or audio/x-m4a
@ -1207,7 +1241,6 @@ export default function PostContent({ @@ -1207,7 +1241,6 @@ export default function PostContent({
} else if (/\.mp3$/i.test(fileName) && !mimeType.startsWith('audio/')) {
mimeType = 'audio/mpeg'
}
}
if (mimeType) {
basicImetaTag.push(`m ${mimeType}`)
}
@ -1225,7 +1258,7 @@ export default function PostContent({ @@ -1225,7 +1258,7 @@ export default function PostContent({
}
}, 100)
} else {
// Non-audio media in replies - don't set mediaNoteKind, will be handled as regular comment
// Non-audio media in replies/PMs - don't set mediaNoteKind, will be handled as regular comment/PM
// Clear any existing media note kind
console.log('❌ handleMediaUploadSuccess: file is not audio, clearing mediaNoteKind', {
fileType,
@ -1237,7 +1270,7 @@ export default function PostContent({ @@ -1237,7 +1270,7 @@ export default function PostContent({
setMediaImetaTags([])
// Just add the media URL to the text content
textareaRef.current?.appendText(url, true)
return // Don't set media note kind for non-audio in replies
return // Don't set media note kind for non-audio in replies/PMs
}
} else {
// For new posts, check if file is ambiguous (could be audio or video)
@ -1731,8 +1764,8 @@ export default function PostContent({ @@ -1731,8 +1764,8 @@ export default function PostContent({
)}
<div className="flex items-center justify-between">
<div className="flex gap-2 items-center">
{/* Audio button for replies - placed before image button */}
{parentEvent && (
{/* Audio button for replies and new PMs - placed before image button */}
{(parentEvent || isPublicMessage) && (
<Uploader
onUploadSuccess={handleMediaUploadSuccess}
onUploadStart={handleUploadStart}
@ -1744,8 +1777,8 @@ export default function PostContent({ @@ -1744,8 +1777,8 @@ export default function PostContent({
type="button"
variant="ghost"
size="icon"
title={t('Upload Audio Comment')}
className={mediaNoteKind === ExtendedKind.VOICE_COMMENT ? 'bg-accent' : ''}
title={parentEvent ? t('Upload Audio Comment') : t('Upload Audio Message')}
className={mediaNoteKind === ExtendedKind.VOICE_COMMENT || (isPublicMessage && mediaNoteKind === ExtendedKind.VOICE) ? 'bg-accent' : ''}
>
<Mic className="h-4 w-4" />
</Button>

21
src/components/PostEditor/PostRelaySelector.tsx

@ -123,6 +123,7 @@ export default function PostRelaySelector({ @@ -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({ @@ -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({ @@ -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,7 +313,17 @@ export default function PostRelaySelector({ @@ -311,7 +313,17 @@ export default function PostRelaySelector({
<div className="text-sm text-muted-foreground p-2">{t('No relays available')}</div>
) : (
<div className="space-y-1">
{selectableRelays.map((url) => {
{(() => {
// 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 (
<div
@ -326,7 +338,8 @@ export default function PostRelaySelector({ @@ -326,7 +338,8 @@ export default function PostRelaySelector({
<span className="text-sm flex-1 truncate">{simplifyUrl(url)}</span>
</div>
)
})}
})
})()}
</div>
)}
</>

12
src/lib/draft-event.ts

@ -301,6 +301,7 @@ export async function createPublicMessageReplyDraftEvent( @@ -301,6 +301,7 @@ export async function createPublicMessageReplyDraftEvent(
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
mediaImetaTags?: string[][] // Allow media imeta tags for audio/video
} = {}
): Promise<TDraftEvent> {
// Process content to prefix nostr addresses before other transformations
@ -317,6 +318,11 @@ export async function createPublicMessageReplyDraftEvent( @@ -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( @@ -383,6 +389,7 @@ export async function createPublicMessageDraftEvent(
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
mediaImetaTags?: string[][] // Allow media imeta tags for audio/video
} = {}
): Promise<TDraftEvent> {
// Process content to prefix nostr addresses before other transformations
@ -393,6 +400,11 @@ export async function createPublicMessageDraftEvent( @@ -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))

173
src/services/relay-selection.service.ts

@ -20,6 +20,7 @@ export interface RelaySelectionContext { @@ -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,35 +386,86 @@ class RelaySelectionService { @@ -385,35 +386,86 @@ 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<string[]> {
const { userWriteRelays, parentEvent, isPublicMessage, content, userPubkey } = context
const relays = new Set<string>()
const { userWriteRelays, parentEvent, isPublicMessage, content, mentions, userPubkey } = context
// Map to track which relays belong to which members
const relayToMembers = new Map<string, Set<string>>()
const allMembers = new Set<string>()
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
// 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
}
}
senderRelays.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(userPubkey)
}
})
}
// 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)
// Get recipients and their inboxes (read relays)
let recipientPubkeys: string[] = []
if (mentionedPubkeys.length > 0) {
const receiverRelayLists = await Promise.all(
mentionedPubkeys.map(async (pubkey) => {
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 {
const relayList = await client.fetchRelayList(pubkey)
const userRelays = relayList?.read || []
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) {
@ -422,40 +474,87 @@ class RelaySelectionService { @@ -422,40 +474,87 @@ class RelaySelectionService {
}
})
)
receiverRelayLists.flat().forEach(url => {
// 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)
}
})
})
}
} 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)
// 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 {
relays.add(url)
// 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<string, string>()
// 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)
})
}
}
// 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)
}

Loading…
Cancel
Save