From 58b97e069506f20ac342cb5ff32b41a079c74e62 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 10 Jan 2026 18:47:19 +0100 Subject: [PATCH 1/5] =?UTF-8?q?Voice=20reply=20to=20VoiceEvent/VoiceReplyE?= =?UTF-8?q?vent=20(KIND=201222/1244)=20=E2=86=92=20Creates=20VoiceReplyEve?= =?UTF-8?q?nt=20(KIND=201244)=20Voice=20reply=20to=20TextNoteEvent=20(KIND?= =?UTF-8?q?=201)=20=E2=86=92=20Creates=20TextNoteEvent=20(KIND=201)=20with?= =?UTF-8?q?=20audio=20IMeta=20attachment=20and=20proper=20reply=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/screen/loggedIn/AccountViewModel.kt | 49 +++++++++++++++---- .../loggedIn/home/ShortNotePostViewModel.kt | 43 +++++++++++++--- .../loggedIn/home/VoiceReplyViewModel.kt | 36 +++++++++++--- 3 files changed, 105 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9c983e475..085653a95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -107,6 +107,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.tags.markedETags +import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -134,6 +137,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -1154,8 +1158,6 @@ class AccountViewModel( context: Context, ) { if (isWriteable()) { - val hint = note.toEventHint() ?: return - launchSigner { val uploader = UploadOrchestrator() val result = @@ -1171,14 +1173,41 @@ class AccountViewModel( ) if (result is UploadingState.Finished && result.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - account.sendVoiceReplyMessage( - result.result.url, - result.result.fileHeader.mimeType ?: recording.mimeType, - result.result.fileHeader.hash, - recording.duration, - recording.amplitudes, - hint, - ) + val audioMeta = + AudioMeta( + url = result.result.url, + mimeType = result.result.fileHeader.mimeType ?: recording.mimeType, + hash = result.result.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, + ) + + // Check if replying to a voice event + val voiceHint = note.toEventHint() + if (voiceHint != null) { + // Create VoiceReplyEvent (KIND 1244) for voice-to-voice replies + account.sendVoiceReplyMessage( + result.result.url, + result.result.fileHeader.mimeType ?: recording.mimeType, + result.result.fileHeader.hash, + recording.duration, + recording.amplitudes, + voiceHint, + ) + } else { + // Create TextNoteEvent (KIND 1) with audio IMeta for voice replies to regular notes + val template = + TextNoteEvent.build(audioMeta.url) { + val replyingTo = note.toEventHint() + if (replyingTo != null) { + val tags = prepareETagsAsReplyTo(replyingTo, null) + markedETags(tags) + } + // Add audio as IMeta attachment + add(audioMeta.toIMetaArray()) + } + account.signAndComputeBroadcast(template) + } } else if (result is UploadingState.Error) { toastManager.toast( R.string.failed_to_upload_media_no_details, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index cd479b134..8495a3083 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -542,18 +542,49 @@ open class ShortNotePostViewModel : voiceMetadata?.let { audioMeta -> // Only create voice reply if original note is also a voice message val originalVoiceHint = originalNote?.toEventHint() - return if (originalVoiceHint != null) { - // Create voice reply event - VoiceReplyEvent.build( + if (originalVoiceHint != null) { + // Create voice reply event (KIND 1244) + return VoiceReplyEvent.build( voiceMessage = audioMeta, replyingTo = originalVoiceHint, ) - } else { - // Create root voice event (no reply or original is not a voice message) - VoiceEvent.build( + } + // If no original note, create a standalone voice event (KIND 1222) + if (originalNote == null) { + return VoiceEvent.build( voiceMessage = audioMeta, ) } + // Otherwise, original note exists but is not a voice message + // Create a TextNoteEvent (KIND 1) with audio as IMeta attachment + return TextNoteEvent.build(audioMeta.url) { + val replyingTo = originalNote?.toEventHint() + if (replyingTo != null) { + val tags = prepareETagsAsReplyTo(replyingTo, null) + tags.forEach { + val note = accountViewModel.getNoteIfExists(it.eventId) + val ourAuthor = note?.author?.pubkeyHex + val ourHint = note?.relayHintUrl() + if (it.author == null || it.author?.isBlank() == true) { + it.author = ourAuthor + } else { + if (ourAuthor != null && it.author != ourAuthor) { + it.author = ourAuthor + } + } + if (it.relay == null) { + it.relay = ourHint + } else { + if (ourHint != null && it.relay != ourHint) { + it.relay = ourHint + } + } + } + markedETags(tags) + } + // Add audio as IMeta attachment + add(audioMeta.toIMetaArray()) + } } val tagger = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index faa5d677a..097aac737 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -37,6 +37,10 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.tags.markedETags +import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent @@ -200,12 +204,6 @@ class VoiceReplyViewModel : ViewModel() { is UploadingState.Finished -> { when (val orchestratorResult = result.result) { is UploadOrchestrator.OrchestratorResult.ServerResult -> { - val hint = note.toEventHint() - if (hint == null) { - accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) - return - } - val audioMeta = AudioMeta( url = orchestratorResult.url, @@ -215,7 +213,31 @@ class VoiceReplyViewModel : ViewModel() { waveform = recording.amplitudes, ) - accountViewModel.account.signAndComputeBroadcast(VoiceReplyEvent.build(audioMeta, hint)) + // Check if replying to a voice event + val voiceHint = note.toEventHint() + if (voiceHint != null) { + // Create VoiceReplyEvent (KIND 1244) for voice-to-voice replies + accountViewModel.account.signAndComputeBroadcast(VoiceReplyEvent.build(audioMeta, voiceHint)) + } else { + // Create TextNoteEvent (KIND 1) with audio IMeta for voice replies to regular notes + val textHint = note.toEventHint() + if (textHint == null) { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + return + } + + val template = + TextNoteEvent.build(audioMeta.url) { + val replyingTo = note.toEventHint() + if (replyingTo != null) { + val tags = prepareETagsAsReplyTo(replyingTo, null) + markedETags(tags) + } + // Add audio as IMeta attachment + add(audioMeta.toIMetaArray()) + } + accountViewModel.account.signAndComputeBroadcast(template) + } if (server.type != ServerType.NIP95) { accountViewModel.account.settings.changeDefaultFileServer(server) From d8093f64b10044d3199a1270099cad4442466672 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 11 Jan 2026 10:27:12 +0100 Subject: [PATCH 2/5] Display kind 1 voice replies as audio waveform --- .../amethyst/ui/note/types/Text.kt | 7 ++ .../amethyst/ui/note/types/VoiceTrack.kt | 84 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index 8432deaa2..1a35de21e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -68,6 +68,13 @@ fun RenderTextEvent( ) { val noteEvent = note.event ?: return + // Check if this is an audio-only event (content is just an audio URL with waveform IMeta) + val isAudioOnly = remember(noteEvent) { noteEvent.isAudioOnlyContent() } + if (isAudioOnly) { + RenderAudioFromIMeta(note, accountViewModel, nav) + return + } + val showReply by remember(note) { derivedStateOf { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt index 0a0210fb0..34939c9a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -69,8 +69,11 @@ import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.Size75Modifier import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent @Composable @@ -263,3 +266,84 @@ fun PlayPauseButton(controllerState: MediaControllerState) { } } } + +/** + * Extracts AudioMeta from an event's IMeta tags if it has audio content with waveform. + * Returns the first audio IMeta that has a waveform, or null if none found. + */ +fun Event.getAudioMetaWithWaveform(): AudioMeta? { + val audioMetas = imetas().map { AudioMeta.parse(it) } + return audioMetas.firstOrNull { meta -> + meta.waveform != null && + meta.mimeType?.startsWith("audio/") == true + } +} + +/** + * Checks if the event content is primarily an audio attachment (content is just the audio URL). + */ +fun Event.isAudioOnlyContent(): Boolean { + val audioMeta = getAudioMetaWithWaveform() ?: return false + return content.trim() == audioMeta.url +} + +/** + * Renders audio with waveform for any event type that has audio IMeta attachment. + * This allows KIND 1 (TextNoteEvent) and KIND 1111 (CommentEvent) with voice + * attachments to display the same waveform UI as VoiceEvent. + */ +@Composable +fun RenderAudioFromIMeta( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event ?: return + + val audioMeta = remember(noteEvent) { noteEvent.getAudioMetaWithWaveform() } ?: return + + val waveform = + remember(audioMeta) { + audioMeta.waveform?.let { WaveformData(it) } + } + + val callbackUri = remember(note) { note.toNostrUri() } + + Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + GetMediaItem( + videoUri = audioMeta.url, + title = null, + artworkUri = null, + authorName = note.author?.toBestDisplayName(), + callbackUri = callbackUri, + mimeType = audioMeta.mimeType, + aspectRatio = null, + proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audioMeta.url), + keepPlaying = false, + waveformData = waveform, + ) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + muted = false, + ) { controller -> + RenderVoicePlayer( + mediaItem = mediaItem, + controllerState = controller, + waveform = waveform, + borderModifier = MaterialTheme.colorScheme.imageModifier, + accountViewModel = accountViewModel, + ) + } + } + } + + if (noteEvent.hasHashtags()) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + DisplayUncitedHashtags(noteEvent, callbackUri, accountViewModel, nav) + } + } + } +} From 70c638ad9043f55be8ec393f46af3858766d8bc7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 11 Jan 2026 10:33:05 +0100 Subject: [PATCH 3/5] refactor to a shared composable to eliminate waveform code duplication --- .../amethyst/ui/note/types/VoiceTrack.kt | 84 +++++++++---------- 1 file changed, 40 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt index 34939c9a8..632d6775b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -110,6 +110,32 @@ fun VoiceHeader( ?.let { WaveformData(it) } } + RenderAudioWithWaveform( + mediaUrl = media, + title = noteEvent.subject(), + mimeType = null, + waveform = waveform, + note = note, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +/** + * Shared composable for rendering audio with waveform visualization. + * Used by both VoiceHeader (for BaseVoiceEvent) and RenderAudioFromIMeta (for other events with audio IMeta). + */ +@Composable +fun RenderAudioWithWaveform( + mediaUrl: String, + title: String?, + mimeType: String?, + waveform: WaveformData?, + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event ?: return val callbackUri = remember(note) { note.toNostrUri() } Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) { @@ -117,14 +143,14 @@ fun VoiceHeader( verticalAlignment = Alignment.CenterVertically, ) { GetMediaItem( - videoUri = media, - title = noteEvent.subject(), + videoUri = mediaUrl, + title = title, artworkUri = null, authorName = note.author?.toBestDisplayName(), callbackUri = callbackUri, - mimeType = null, + mimeType = mimeType, aspectRatio = null, - proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(media), + proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(mediaUrl), keepPlaying = false, waveformData = waveform, ) { mediaItem -> @@ -171,7 +197,7 @@ fun RenderVoicePlayer( factory = { context: Context -> PlayerView(context).apply { player = controllerState.controller - // if we alrady know the size of the frame, this forces the player to stay in the size + // if we already know the size of the frame, this forces the player to stay in the size layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, @@ -307,43 +333,13 @@ fun RenderAudioFromIMeta( audioMeta.waveform?.let { WaveformData(it) } } - val callbackUri = remember(note) { note.toNostrUri() } - - Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - GetMediaItem( - videoUri = audioMeta.url, - title = null, - artworkUri = null, - authorName = note.author?.toBestDisplayName(), - callbackUri = callbackUri, - mimeType = audioMeta.mimeType, - aspectRatio = null, - proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audioMeta.url), - keepPlaying = false, - waveformData = waveform, - ) { mediaItem -> - GetVideoController( - mediaItem = mediaItem, - muted = false, - ) { controller -> - RenderVoicePlayer( - mediaItem = mediaItem, - controllerState = controller, - waveform = waveform, - borderModifier = MaterialTheme.colorScheme.imageModifier, - accountViewModel = accountViewModel, - ) - } - } - } - - if (noteEvent.hasHashtags()) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - DisplayUncitedHashtags(noteEvent, callbackUri, accountViewModel, nav) - } - } - } + RenderAudioWithWaveform( + mediaUrl = audioMeta.url, + title = null, + mimeType = audioMeta.mimeType, + waveform = waveform, + note = note, + accountViewModel = accountViewModel, + nav = nav, + ) } From f51d0fb56e5741fffea45c7f7c48d27da5f8dd03 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 11 Jan 2026 17:39:08 +0100 Subject: [PATCH 4/5] code review: added p-tag for voice reply to kind 1 allow waveform IMeta with a missing mimeType to render as audio --- .../vitorpamplona/amethyst/ui/note/types/Text.kt | 14 +++++++------- .../amethyst/ui/note/types/VoiceTrack.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 15 ++++++++------- .../loggedIn/home/ShortNotePostViewModel.kt | 12 ++++++++++++ .../screen/loggedIn/home/VoiceReplyViewModel.kt | 3 +++ 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index 1a35de21e..76c68e29b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -68,13 +68,6 @@ fun RenderTextEvent( ) { val noteEvent = note.event ?: return - // Check if this is an audio-only event (content is just an audio URL with waveform IMeta) - val isAudioOnly = remember(noteEvent) { noteEvent.isAudioOnlyContent() } - if (isAudioOnly) { - RenderAudioFromIMeta(note, accountViewModel, nav) - return - } - val showReply by remember(note) { derivedStateOf { @@ -107,6 +100,13 @@ fun RenderTextEvent( } } + // Check if this is an audio-only event (content is just an audio URL with waveform IMeta) + val isAudioOnly = remember(noteEvent) { noteEvent.isAudioOnlyContent() } + if (isAudioOnly) { + RenderAudioFromIMeta(note, accountViewModel, nav) + return + } + LoadDecryptedContent( note, accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt index 632d6775b..1475f1e36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -301,7 +301,7 @@ fun Event.getAudioMetaWithWaveform(): AudioMeta? { val audioMetas = imetas().map { AudioMeta.parse(it) } return audioMetas.firstOrNull { meta -> meta.waveform != null && - meta.mimeType?.startsWith("audio/") == true + meta.mimeType?.startsWith("audio/") != false } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 085653a95..a439463d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -105,10 +105,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser +import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.tags.markedETags +import com.vitorpamplona.quartz.nip10Notes.tags.notify import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -394,7 +396,7 @@ class AccountViewModel( note.flow().author(), note.flow().metadata.stateFlow, note.flow().reports.stateFlow, - ) { hiddenUsers, followingUsers, autor, metadata, reports -> + ) { hiddenUsers, followingUsers, _, metadata, _ -> emit(isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.authors)) }.onStart { emit( @@ -1015,11 +1017,11 @@ class AccountViewModel( LocalCache.findLatestModificationForNote(note) } - fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel? = LocalCache.getOrCreatePublicChatChannel(key) + fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = LocalCache.getOrCreatePublicChatChannel(key) - fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel? = LocalCache.getOrCreateLiveChannel(key) + fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel = LocalCache.getOrCreateLiveChannel(key) - fun checkGetOrCreateEphemeralChatChannel(key: RoomId): EphemeralChatChannel? = LocalCache.getOrCreateEphemeralChannel(key) + fun checkGetOrCreateEphemeralChatChannel(key: RoomId): EphemeralChatChannel = LocalCache.getOrCreateEphemeralChannel(key) fun getPublicChatChannelIfExists(hex: HexKey) = LocalCache.getPublicChatChannelIfExists(hex) @@ -1202,6 +1204,7 @@ class AccountViewModel( if (replyingTo != null) { val tags = prepareETagsAsReplyTo(replyingTo, null) markedETags(tags) + notify(replyingTo.toPTag()) } // Add audio as IMeta attachment add(audioMeta.toIMetaArray()) @@ -1451,7 +1454,7 @@ class AccountViewModel( // First check if we have an actual response from the DVM in LocalCache val response = LocalCache.notes.maxOrNullOf( - filter = { key, note -> + filter = { _, note -> val noteEvent = note.event noteEvent is NIP90ContentDiscoveryResponseEvent && noteEvent.pubKey == pubkeyHex && @@ -1560,8 +1563,6 @@ class AccountViewModel( } } - fun findUsersStartingWithSync(prefix: String) = LocalCache.findUsersStartingWith(prefix, account) - fun convertAccounts(loggedInAccounts: List?): Set = loggedInAccounts ?.mapNotNull { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 8495a3083..8eec5fd70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -582,6 +582,18 @@ open class ShortNotePostViewModel : } markedETags(tags) } + pTags?.let { userList -> + val tags = + userList.map { + val tag = it.toPTag() + if (tag.relayHint == null) { + tag.copy(relayHint = LocalCache.relayHints.hintsForKey(it.pubkeyHex).firstOrNull()) + } else { + tag + } + } + notify(tags) + } // Add audio as IMeta attachment add(audioMeta.toIMetaArray()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 097aac737..a34e4f1cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -38,8 +38,10 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.tags.markedETags +import com.vitorpamplona.quartz.nip10Notes.tags.notify import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent @@ -232,6 +234,7 @@ class VoiceReplyViewModel : ViewModel() { if (replyingTo != null) { val tags = prepareETagsAsReplyTo(replyingTo, null) markedETags(tags) + notify(replyingTo.toPTag()) } // Add audio as IMeta attachment add(audioMeta.toIMetaArray()) From 1e898d57b2a4d9ae6e22bd74d56c9adf56e1d7f2 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 11 Jan 2026 18:34:59 +0100 Subject: [PATCH 5/5] code review fixes: textHint variable was retrieved but never used extracted logic for fixing relay hints and author information in tags made mimeType check logic clearer --- .../amethyst/ui/note/types/VoiceTrack.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 112 +++++------------- .../loggedIn/home/ShortNotePostViewModel.kt | 22 +--- .../loggedIn/home/VoiceReplyViewModel.kt | 13 +- 4 files changed, 36 insertions(+), 113 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt index 1475f1e36..6f4d34a2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -301,7 +301,7 @@ fun Event.getAudioMetaWithWaveform(): AudioMeta? { val audioMetas = imetas().map { AudioMeta.parse(it) } return audioMetas.firstOrNull { meta -> meta.waveform != null && - meta.mimeType?.startsWith("audio/") != false + (meta.mimeType == null || meta.mimeType?.startsWith("audio/") == true) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index a439463d0..b24b67d58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.rememberCoroutineScope -import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -67,12 +66,8 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler -import com.vitorpamplona.amethyst.service.uploads.CompressorQuality -import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk -import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.feeds.FeedState @@ -105,13 +100,9 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip10Notes.tags.markedETags -import com.vitorpamplona.quartz.nip10Notes.tags.notify -import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -139,8 +130,6 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta -import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -1004,6 +993,32 @@ class AccountViewModel( fun getNoteIfExists(hex: HexKey): Note? = LocalCache.getNoteIfExists(hex) + /** + * Fixes author and relay hints in MarkedETag list by looking up notes from cache. + * This ensures reply tags have proper author pubkeys and relay hints for threading. + */ + fun fixReplyTagHints(tags: List) { + tags.forEach { tag -> + val note = getNoteIfExists(tag.eventId) + val cachedAuthor = note?.author?.pubkeyHex + val cachedRelay = note?.relayHintUrl() + + // Fix author if missing or different from cached + if (tag.author.isNullOrBlank() && cachedAuthor != null) { + tag.author = cachedAuthor + } else if (cachedAuthor != null && tag.author != cachedAuthor) { + tag.author = cachedAuthor + } + + // Fix relay hint if missing or different from cached + if (tag.relay == null && cachedRelay != null) { + tag.relay = cachedRelay + } else if (cachedRelay != null && tag.relay != cachedRelay) { + tag.relay = cachedRelay + } + } + } + override suspend fun getOrCreateAddressableNote(address: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(address) fun getAddressableNoteIfExists(key: String): AddressableNote? = LocalCache.getAddressableNoteIfExists(key) @@ -1154,79 +1169,6 @@ class AccountViewModel( super.onCleared() } - fun sendVoiceReply( - note: Note, - recording: RecordingResult, - context: Context, - ) { - if (isWriteable()) { - launchSigner { - val uploader = UploadOrchestrator() - val result = - uploader.upload( - uri = recording.file.toUri(), - mimeType = recording.mimeType, - alt = null, - contentWarningReason = null, - compressionQuality = CompressorQuality.UNCOMPRESSED, - server = account.settings.defaultFileServer, - account = account, - context = context, - ) - - if (result is UploadingState.Finished && result.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - val audioMeta = - AudioMeta( - url = result.result.url, - mimeType = result.result.fileHeader.mimeType ?: recording.mimeType, - hash = result.result.fileHeader.hash, - duration = recording.duration, - waveform = recording.amplitudes, - ) - - // Check if replying to a voice event - val voiceHint = note.toEventHint() - if (voiceHint != null) { - // Create VoiceReplyEvent (KIND 1244) for voice-to-voice replies - account.sendVoiceReplyMessage( - result.result.url, - result.result.fileHeader.mimeType ?: recording.mimeType, - result.result.fileHeader.hash, - recording.duration, - recording.amplitudes, - voiceHint, - ) - } else { - // Create TextNoteEvent (KIND 1) with audio IMeta for voice replies to regular notes - val template = - TextNoteEvent.build(audioMeta.url) { - val replyingTo = note.toEventHint() - if (replyingTo != null) { - val tags = prepareETagsAsReplyTo(replyingTo, null) - markedETags(tags) - notify(replyingTo.toPTag()) - } - // Add audio as IMeta attachment - add(audioMeta.toIMetaArray()) - } - account.signAndComputeBroadcast(template) - } - } else if (result is UploadingState.Error) { - toastManager.toast( - R.string.failed_to_upload_media_no_details, - result.errorResource, - *result.params, - ) - } - } - } else { - toastManager.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_reply, - ) - } - } - fun loadThumb( context: Context, thumbUri: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 8eec5fd70..d810088ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -83,6 +83,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -561,26 +562,9 @@ open class ShortNotePostViewModel : val replyingTo = originalNote?.toEventHint() if (replyingTo != null) { val tags = prepareETagsAsReplyTo(replyingTo, null) - tags.forEach { - val note = accountViewModel.getNoteIfExists(it.eventId) - val ourAuthor = note?.author?.pubkeyHex - val ourHint = note?.relayHintUrl() - if (it.author == null || it.author?.isBlank() == true) { - it.author = ourAuthor - } else { - if (ourAuthor != null && it.author != ourAuthor) { - it.author = ourAuthor - } - } - if (it.relay == null) { - it.relay = ourHint - } else { - if (ourHint != null && it.relay != ourHint) { - it.relay = ourHint - } - } - } + accountViewModel.fixReplyTagHints(tags) markedETags(tags) + notify(replyingTo.toPTag()) } pTags?.let { userList -> val tags = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index a34e4f1cf..3bd707f81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -37,7 +37,6 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.tags.markedETags @@ -222,7 +221,7 @@ class VoiceReplyViewModel : ViewModel() { accountViewModel.account.signAndComputeBroadcast(VoiceReplyEvent.build(audioMeta, voiceHint)) } else { // Create TextNoteEvent (KIND 1) with audio IMeta for voice replies to regular notes - val textHint = note.toEventHint() + val textHint = note.toEventHint() if (textHint == null) { accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) return @@ -230,12 +229,10 @@ class VoiceReplyViewModel : ViewModel() { val template = TextNoteEvent.build(audioMeta.url) { - val replyingTo = note.toEventHint() - if (replyingTo != null) { - val tags = prepareETagsAsReplyTo(replyingTo, null) - markedETags(tags) - notify(replyingTo.toPTag()) - } + val tags = prepareETagsAsReplyTo(textHint, null) + accountViewModel.fixReplyTagHints(tags) + markedETags(tags) + notify(textHint.toPTag()) // Add audio as IMeta attachment add(audioMeta.toIMetaArray()) }