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. 27
      src/components/NotificationList/NotificationItem/Notification.tsx
  5. 161
      src/components/PostEditor/PostContent.tsx
  6. 47
      src/components/PostEditor/PostRelaySelector.tsx
  7. 12
      src/lib/draft-event.ts
  8. 203
      src/services/relay-selection.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "14.0", "version": "14.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "14.0", "version": "14.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "jumble-imwald", "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", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true, "private": true,
"type": "module", "type": "module",

13
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 // Add "Create Highlight" action for OP events
if (isOPEvent && openHighlightEditor) { if (isOPEvent && openHighlightEditor) {
actions.push({ actions.push({

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

@ -24,7 +24,8 @@ export default function Notification({
middle = null, middle = null,
targetEvent, targetEvent,
isNew = false, isNew = false,
showStats = false showStats = false,
rightAction = null
}: { }: {
icon: React.ReactNode icon: React.ReactNode
notificationId: string notificationId: string
@ -35,6 +36,7 @@ export default function Notification({
targetEvent?: NostrEvent targetEvent?: NostrEvent
isNew?: boolean isNew?: boolean
showStats?: boolean showStats?: boolean
rightAction?: React.ReactNode
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
@ -122,16 +124,19 @@ export default function Notification({
/> />
<div className="shrink-0 text-muted-foreground text-sm">{description}</div> <div className="shrink-0 text-muted-foreground text-sm">{description}</div>
</div> </div>
{unread && ( <div className="flex items-center gap-1 shrink-0">
<button {rightAction}
className="m-0.5 size-3 bg-primary rounded-full shrink-0 transition-all hover:ring-4 hover:ring-primary/20" {unread && (
title={t('Mark as read')} <button
onClick={(e) => { className="m-0.5 size-3 bg-primary rounded-full shrink-0 transition-all hover:ring-4 hover:ring-primary/20"
e.stopPropagation() title={t('Mark as read')}
markNotificationAsRead(notificationId) onClick={(e) => {
}} e.stopPropagation()
/> markNotificationAsRead(notificationId)
)} }}
/>
)}
</div>
</div> </div>
{middle} {middle}
{targetEvent && ( {targetEvent && (

161
src/components/PostEditor/PostContent.tsx

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

47
src/components/PostEditor/PostRelaySelector.tsx

@ -123,6 +123,7 @@ export default function PostRelaySelector({
parentEvent: _parentEvent, parentEvent: _parentEvent,
isPublicMessage, isPublicMessage,
content: isDiscussionReply ? '' : postContent, // Don't use content for discussion replies content: isDiscussionReply ? '' : postContent, // Don't use content for discussion replies
mentions: isPublicMessage ? mentions : undefined, // Pass mentions for PMs
userPubkey: pubkey || undefined, userPubkey: pubkey || undefined,
openFrom: memoizedOpenFrom openFrom: memoizedOpenFrom
}) })
@ -161,7 +162,7 @@ export default function PostRelaySelector({
} }
updateRelaySelection() 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 // Separate effect for mention changes in non-discussion replies
useEffect(() => { useEffect(() => {
@ -213,7 +214,8 @@ export default function PostRelaySelector({
relaySets: memoizedRelaySets, relaySets: memoizedRelaySets,
parentEvent: _parentEvent, parentEvent: _parentEvent,
isPublicMessage, isPublicMessage,
content: postContent, content: isDiscussionReply ? '' : postContent, // Don't use content for discussion replies
mentions: isPublicMessage ? mentions : undefined, // Pass mentions for PMs
userPubkey: pubkey || undefined, userPubkey: pubkey || undefined,
openFrom: memoizedOpenFrom openFrom: memoizedOpenFrom
}) })
@ -311,22 +313,33 @@ export default function PostRelaySelector({
<div className="text-sm text-muted-foreground p-2">{t('No relays available')}</div> <div className="text-sm text-muted-foreground p-2">{t('No relays available')}</div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{selectableRelays.map((url) => { {(() => {
const isChecked = selectedRelayUrls.includes(url) // Sort relays so selected ones appear at the top
return ( const sortedRelays = [...selectableRelays].sort((a, b) => {
<div const aSelected = selectedRelayUrls.includes(a)
key={url} const bSelected = selectedRelayUrls.includes(b)
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer touch-manipulation" if (aSelected && !bSelected) return -1
onClick={() => handleRelayCheckedChange(!isChecked, url)} if (!aSelected && bSelected) return 1
> return 0
<div className="flex items-center justify-center w-4 h-4 border border-border rounded"> })
{isChecked && <Check className="w-3 h-3" />}
return sortedRelays.map((url) => {
const isChecked = selectedRelayUrls.includes(url)
return (
<div
key={url}
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer touch-manipulation"
onClick={() => handleRelayCheckedChange(!isChecked, url)}
>
<div className="flex items-center justify-center w-4 h-4 border border-border rounded">
{isChecked && <Check className="w-3 h-3" />}
</div>
<RelayIcon url={url} className="w-4 h-4" />
<span className="text-sm flex-1 truncate">{simplifyUrl(url)}</span>
</div> </div>
<RelayIcon url={url} className="w-4 h-4" /> )
<span className="text-sm flex-1 truncate">{simplifyUrl(url)}</span> })
</div> })()}
)
})}
</div> </div>
)} )}
</> </>

12
src/lib/draft-event.ts

@ -301,6 +301,7 @@ export async function createPublicMessageReplyDraftEvent(
expirationMonths?: number expirationMonths?: number
addQuietTag?: boolean addQuietTag?: boolean
quietDays?: number quietDays?: number
mediaImetaTags?: string[][] // Allow media imeta tags for audio/video
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
// Process content to prefix nostr addresses before other transformations // Process content to prefix nostr addresses before other transformations
@ -317,6 +318,11 @@ export async function createPublicMessageReplyDraftEvent(
.concat(quoteEventHexIds.map((eventId) => buildQTag(eventId))) .concat(quoteEventHexIds.map((eventId) => buildQTag(eventId)))
.concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate))) .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) const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) { if (images && images.length) {
tags.push(...generateImetaTags(images)) tags.push(...generateImetaTags(images))
@ -383,6 +389,7 @@ export async function createPublicMessageDraftEvent(
expirationMonths?: number expirationMonths?: number
addQuietTag?: boolean addQuietTag?: boolean
quietDays?: number quietDays?: number
mediaImetaTags?: string[][] // Allow media imeta tags for audio/video
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
// Process content to prefix nostr addresses before other transformations // Process content to prefix nostr addresses before other transformations
@ -393,6 +400,11 @@ export async function createPublicMessageDraftEvent(
const tags = emojiTags const tags = emojiTags
.concat(hashtags.map((hashtag) => buildTTag(hashtag))) .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) const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) { if (images && images.length) {
tags.push(...generateImetaTags(images)) tags.push(...generateImetaTags(images))

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

@ -20,6 +20,7 @@ export interface RelaySelectionContext {
parentEvent?: Event parentEvent?: Event
isPublicMessage?: boolean isPublicMessage?: boolean
content?: string content?: string
mentions?: string[] // Pre-extracted mentions (for PMs)
userPubkey?: string userPubkey?: string
openFrom?: string[] openFrom?: string[]
} }
@ -385,77 +386,175 @@ class RelaySelectionService {
/** /**
* Get relays for public messages: sender outboxes + receiver inboxes * 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[]> { private async getPublicMessageRelays(context: RelaySelectionContext): Promise<string[]> {
const { userWriteRelays, parentEvent, isPublicMessage, content, userPubkey } = context const { userWriteRelays, parentEvent, isPublicMessage, content, mentions, userPubkey } = context
const relays = new Set<string>()
// Map to track which relays belong to which members
const relayToMembers = new Map<string, Set<string>>()
const allMembers = new Set<string>()
try { try {
// Add sender's write relays (outboxes) - fallback to fast write relays if no user relays // Get sender's outboxes (write relays)
const senderRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS if (userPubkey) {
senderRelays.forEach(url => { allMembers.add(userPubkey)
const normalized = normalizeUrl(url) let senderRelays = userWriteRelays
if (normalized) {
relays.add(normalized) // If userWriteRelays is empty, try to fetch the user's relay list
} else { if (senderRelays.length === 0) {
relays.add(url) 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) { senderRelays.forEach(url => {
const receiverRelayLists = await Promise.all( const normalized = normalizeUrl(url)
mentionedPubkeys.map(async (pubkey) => { if (normalized) {
try { if (!relayToMembers.has(normalized)) {
const relayList = await client.fetchRelayList(pubkey) relayToMembers.set(normalized, new Set())
const userRelays = relayList?.read || [] }
// Filter out local relays from other users relayToMembers.get(normalized)!.add(userPubkey)
return this.filterLocalRelaysFromOthers(userRelays) }
} catch (error) { })
logger.warn('Failed to fetch relay list', { pubkey, error }) }
return []
} // Get recipients and their inboxes (read relays)
}) let recipientPubkeys: string[] = []
)
receiverRelayLists.flat().forEach(url => { 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) const normalized = normalizeUrl(url)
if (normalized) { if (normalized) {
relays.add(normalized) if (!relayToMembers.has(normalized)) {
} else { relayToMembers.set(normalized, new Set())
relays.add(url) }
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<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)
})
}
} }
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
// For public message replies, get original sender's read relays (filter out their local relays) // Second pass: ensure each member has at least one relay
// Use cached version from IndexedDB instead of fetching from relays for (const [relayUrl, members] of sortedRelays) {
try { if (relays.length >= 10) break
const senderRelayList = await this.getCachedRelayList(parentEvent.pubkey)
if (senderRelayList?.read) { // Check if any member still needs a relay
const filteredRelays = this.filterLocalRelaysFromOthers(senderRelayList.read) const needsRelay = Array.from(members).some(member => !selectedForMember.has(member))
filteredRelays.forEach(url => { if (needsRelay) {
const normalized = normalizeUrl(url) relays.push(relayUrl)
if (normalized) { members.forEach(member => {
relays.add(normalized) if (!selectedForMember.has(member)) {
} else { selectedForMember.set(member, relayUrl)
relays.add(url)
} }
}) })
} }
} 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) { } catch (error) {
logger.error('Failed to get public message relays', { error, parentEvent: context.parentEvent?.id }) 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