diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index f65b84ef8..1adc0f659 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -23,6 +23,9 @@
+
+
+
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
index d19d58512..b805dc2c9 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
@@ -204,6 +204,8 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
+import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
+import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -960,6 +962,27 @@ class Account(
cache.getNoteIfExists(signedEvent.id)?.let { onReady(it) }
}
+ suspend fun sendVoiceMessage(
+ url: String,
+ mimeType: String?,
+ hash: String,
+ duration: Int,
+ waveform: List,
+ ) {
+ signAndComputeBroadcast(VoiceEvent.build(url, mimeType, hash, duration, waveform))
+ }
+
+ suspend fun sendVoiceReplyMessage(
+ url: String,
+ mimeType: String?,
+ hash: String,
+ duration: Int,
+ waveform: List,
+ replyTo: EventHintBundle,
+ ) {
+ signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo))
+ }
+
suspend fun sendAllAsOnePictureEvent(
urlHeaderInfo: Map,
caption: String?,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
index 8409dd907..03372d5cd 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
@@ -782,6 +782,8 @@ object LocalCache : ILocalCache {
is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
+ is VoiceReplyEvent -> event.markedReplyTos().mapNotNull { checkGetOrCreateNote(it) }
+
is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
is ChatMessageEncryptedFileHeaderEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt
new file mode 100644
index 000000000..8f2eae9b8
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.actions.uploads
+
+import android.Manifest
+import android.widget.Toast
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable
+import com.vitorpamplona.amethyst.ui.stringRes
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun RecordAudioBox(
+ modifier: Modifier,
+ onRecordTaken: (RecordingResult) -> Unit,
+ content: @Composable (Boolean) -> Unit,
+) {
+ val mediaRecorder = remember { mutableStateOf(null) }
+ val context = LocalContext.current
+
+ ClickAndHoldBoxComposable(
+ modifier = modifier,
+ onPress = {
+ val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
+ val scope = rememberCoroutineScope()
+ LaunchedEffect(Unit) {
+ if (!recordPermissionState.status.isGranted) {
+ recordPermissionState.launchPermissionRequest()
+ } else {
+ mediaRecorder.value = VoiceMessageRecorder()
+ mediaRecorder.value?.start(context, scope)
+ }
+ }
+ },
+ onRelease = {
+ val result = mediaRecorder.value?.stop()
+ if (result != null) {
+ onRecordTaken(result)
+ } else {
+ // less disruptive than error messages
+ Toast
+ .makeText(
+ context,
+ stringRes(context, R.string.record_a_message_description),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ },
+ onCancel = {
+ mediaRecorder.value?.stop()
+ },
+ content,
+ )
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt
index 047336d13..b5675ee8a 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt
@@ -129,7 +129,7 @@ fun PictureButton(onClick: () -> Unit) {
) {
Icon(
imageVector = Icons.Default.CameraAlt,
- contentDescription = stringRes(id = R.string.upload_image),
+ contentDescription = stringRes(id = R.string.take_a_picture),
modifier = Modifier.height(22.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt
new file mode 100644
index 000000000..ba98a19c4
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt
@@ -0,0 +1,106 @@
+/**
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.actions.uploads
+
+import android.content.Context
+import android.media.MediaRecorder
+import android.os.Build
+import androidx.media3.common.MimeTypes
+import com.vitorpamplona.quartz.utils.RandomInstance
+import com.vitorpamplona.quartz.utils.TimeUtils
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.io.File
+
+class RecordingResult(
+ val file: File,
+ val mimeType: String,
+ val amplitudes: List,
+ val duration: Int,
+)
+
+class VoiceMessageRecorder {
+ private var recorder: MediaRecorder? = null
+ private var outputFile: File? = null
+ private var startTime: Long = 0
+ private var job: Job? = null
+ private var amplitudes: MutableList = mutableListOf()
+
+ private fun createRecorder(context: Context): MediaRecorder =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaRecorder(context)
+ } else {
+ MediaRecorder()
+ }
+
+ suspend fun start(
+ context: Context,
+ scope: CoroutineScope,
+ ) {
+ val fileName = RandomInstance.randomChars(16) + ".mp4"
+ val outputFile = File(context.cacheDir, "/voice/$fileName")
+ outputFile.parentFile?.mkdirs()
+ this.outputFile = outputFile
+ this.startTime = TimeUtils.now()
+ this.amplitudes.clear()
+
+ createRecorder(context).apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
+ setOutputFile(outputFile)
+
+ prepare()
+ start()
+
+ recorder = this
+ }
+
+ job?.cancel()
+ job =
+ scope.launch {
+ while (recorder != null) {
+ amplitudes.add(((recorder?.maxAmplitude ?: 0) / 100).toInt())
+ delay(1000)
+ }
+ }
+ }
+
+ suspend fun stop(): RecordingResult? {
+ recorder?.stop()
+ recorder?.reset()
+ recorder = null
+ val currentTime = TimeUtils.now()
+ val file = outputFile
+ return if (currentTime - startTime >= 1 && file != null) {
+ RecordingResult(
+ file,
+ MimeTypes.AUDIO_AAC,
+ amplitudes,
+ (currentTime - startTime).toInt(),
+ )
+ } else {
+ null
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt
index 90110d302..2015fefcb 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt
@@ -20,15 +20,29 @@
*/
package com.vitorpamplona.amethyst.ui.components
+import android.R.attr.onClick
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
+import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
import androidx.compose.ui.semantics.Role
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
@@ -51,6 +65,115 @@ fun ClickableBox(
}
}
+@Composable
+fun ClickAndHoldBox(
+ modifier: Modifier = Modifier,
+ onPress: () -> Unit,
+ onRelease: () -> Unit,
+ content: @Composable (Boolean) -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+
+ LaunchedEffect(isPressed) {
+ if (isPressed) {
+ // Button is pressed
+ onPress()
+ } else {
+ // Button is released
+ onRelease()
+ }
+ }
+
+ // Animation for the button scale
+ val scale by animateFloatAsState(
+ targetValue = if (isPressed) 1.5f else 1.0f, // Scale up when recording
+ animationSpec = tween(durationMillis = 150), // Smooth animation
+ )
+
+ // Animation for the button color
+ val backgroundColor by animateColorAsState(
+ targetValue = if (isPressed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background,
+ animationSpec = tween(durationMillis = 150),
+ )
+
+ Box(
+ modifier
+ .scale(scale)
+ .background(backgroundColor, CircleShape)
+ .clickable(
+ role = Role.Button,
+ interactionSource = interactionSource,
+ indication = ripple24dp,
+ onClick = { },
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ content(isPressed)
+ }
+}
+
+@Composable
+fun ClickAndHoldBoxComposable(
+ modifier: Modifier = Modifier,
+ onPress: @Composable () -> Unit,
+ onRelease: suspend () -> Unit,
+ onCancel: suspend () -> Unit,
+ content: @Composable (Boolean) -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ var isPressed by remember { mutableStateOf(false) }
+
+ if (isPressed) {
+ onPress()
+ }
+
+ LaunchedEffect(interactionSource) {
+ val pressInteractions = mutableListOf()
+ interactionSource.interactions.collect { interaction ->
+ when (interaction) {
+ is PressInteraction.Press -> pressInteractions.add(interaction)
+ is PressInteraction.Release -> {
+ onRelease()
+ pressInteractions.remove(interaction.press)
+ }
+ is PressInteraction.Cancel -> {
+ onCancel()
+ pressInteractions.remove(interaction.press)
+ }
+ }
+ isPressed = pressInteractions.isNotEmpty()
+ }
+ }
+
+ // Animation for the button scale
+ val scale by animateFloatAsState(
+ targetValue = if (isPressed) 1.5f else 1.0f, // Scale up when recording
+ animationSpec = tween(durationMillis = 150), // Smooth animation
+ )
+
+ // Animation for the button color
+ val backgroundColor by animateColorAsState(
+ targetValue = if (isPressed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background,
+ animationSpec = tween(durationMillis = 150),
+ )
+
+ Box(
+ modifier
+ .scale(scale)
+ .background(backgroundColor, CircleShape)
+ .clickable(
+ role = Role.Button,
+ interactionSource = interactionSource,
+ indication = ripple24dp,
+ onClick = { },
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ content(isPressed)
+ }
+}
+
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClickableBox(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
index bfb98950e..89fefa678 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
@@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
@@ -172,6 +173,14 @@ fun AppNavigation(
)
}
+ composableFromBottomArgs {
+ NewPublicMessageScreen(
+ to = it.toKey(),
+ accountViewModel,
+ nav,
+ )
+ }
+
composableFromBottomArgs {
HashtagPostScreen(
hashtag = it.hashtag,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt
index 706f2503a..a51df6440 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
+import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -200,6 +201,7 @@ fun routeReplyTo(
): Route? {
val noteEvent = note.event
return when (noteEvent) {
+ is PublicMessageEvent -> Route.NewPublicMessage(noteEvent.groupKeySet() - account.userProfile().pubkeyHex)
is TextNoteEvent -> Route.NewPost(baseReplyTo = note.idHex)
is PrivateDmEvent ->
routeToMessage(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
index c62974fb8..0dbc6c0a3 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
@@ -138,6 +138,16 @@ sealed class Route {
fun toKey(): ChatroomKey = ChatroomKey(id.split(",").toSet())
}
+ @Serializable data class NewPublicMessage(
+ val to: String,
+ ) : Route() {
+ constructor(users: Set) : this(
+ to = users.joinToString(","),
+ )
+
+ fun toKey(): Set = to.split(",").toSet()
+ }
+
@Serializable data class RoomByAuthor(
val id: String,
) : Route()
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt
index d331ef354..6bd827c59 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt
@@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -314,6 +315,19 @@ fun ExpandMoreIcon(
)
}
+@Composable
+fun VoiceReplyIcon(
+ iconSizeModifier: Modifier,
+ tint: Color,
+) {
+ Icon(
+ imageVector = Icons.Outlined.Mic,
+ contentDescription = stringRes(id = R.string.record_a_message),
+ tint = tint,
+ modifier = iconSizeModifier,
+ )
+}
+
@Composable
fun CommentIcon(
iconSizeModifier: Modifier,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
index b40b7357b..485d665cd 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
@@ -93,6 +93,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.model.FeatureSetType
@@ -109,6 +110,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
+import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
@@ -156,6 +158,7 @@ import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
+import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
@@ -578,8 +581,37 @@ private fun ReplyReactionWithDialog(
accountViewModel: AccountViewModel,
nav: INav,
) {
- ReplyReaction(baseNote, grayTint, accountViewModel) {
- nav.nav { routeReplyTo(baseNote, accountViewModel.account) }
+ if (baseNote.event is BaseVoiceEvent) {
+ ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel)
+ } else {
+ ReplyReaction(baseNote, grayTint, accountViewModel) {
+ nav.nav { routeReplyTo(baseNote, accountViewModel.account) }
+ }
+ }
+}
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun ReplyViaVoiceReaction(
+ baseNote: Note,
+ grayTint: Color,
+ accountViewModel: AccountViewModel,
+ showCounter: Boolean = true,
+ iconSizeModifier: Modifier = Size19Modifier,
+) {
+ val context = LocalContext.current
+
+ RecordAudioBox(
+ modifier = iconSizeModifier,
+ onRecordTaken = { audio ->
+ accountViewModel.sendVoiceReply(baseNote, audio, context)
+ },
+ ) {
+ VoiceReplyIcon(iconSizeModifier, grayTint)
+ }
+
+ if (showCounter) {
+ ReplyCounter(baseNote, grayTint, accountViewModel)
}
}
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 9b72ca41b..690928c22 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,6 +28,7 @@ import android.util.LruCache
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
+import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@@ -65,8 +66,12 @@ import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
+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
@@ -104,6 +109,7 @@ import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
+import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
@@ -123,6 +129,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
+import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.mapNotNullAsync
@@ -1249,6 +1256,54 @@ class AccountViewModel(
super.onCleared()
}
+ fun sendVoiceReply(
+ note: Note,
+ recording: RecordingResult,
+ context: Context,
+ ) {
+ if (isWriteable()) {
+ val hint = note.toEventHint()
+ if (hint == null) return
+
+ runIOCatching {
+ 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) {
+ account.sendVoiceReplyMessage(
+ result.result.url,
+ result.result.fileHeader.mimeType ?: recording.mimeType,
+ result.result.fileHeader.hash,
+ recording.duration,
+ recording.amplitudes,
+ hint,
+ )
+ } 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,
@@ -1673,7 +1728,7 @@ class AccountViewModel(
is NSec -> {}
is NPub -> {}
is NProfile -> {}
- is com.vitorpamplona.quartz.nip19Bech32.entities.NNote -> {
+ is NNote -> {
LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note ->
returningNote = note
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt
new file mode 100644
index 000000000..f8279cc1f
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt
@@ -0,0 +1,379 @@
+/**
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages
+
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment.Companion.CenterVertically
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
+import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
+import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
+import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
+import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
+import com.vitorpamplona.amethyst.ui.navigation.navs.INav
+import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
+import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
+import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
+import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
+import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
+import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
+import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton
+import com.vitorpamplona.amethyst.ui.note.creators.invoice.NewPostInvoiceRequest
+import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton
+import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash
+import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews
+import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton
+import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest
+import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
+import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
+import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton
+import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest
+import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo
+import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.MessageFieldRow
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.SendDirectMessageTo
+import com.vitorpamplona.amethyst.ui.stringRes
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
+import com.vitorpamplona.amethyst.ui.theme.Font14SP
+import com.vitorpamplona.amethyst.ui.theme.Size10dp
+import com.vitorpamplona.amethyst.ui.theme.Size5dp
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
+@Composable
+fun NewPublicMessageScreen(
+ to: Set? = null,
+ accountViewModel: AccountViewModel,
+ nav: Nav,
+) {
+ val postViewModel: NewPublicMessageViewModel = viewModel()
+ postViewModel.init(accountViewModel)
+
+ LaunchedEffect(Unit) {
+ withContext(Dispatchers.IO) {
+ to?.let {
+ postViewModel.load(it)
+ }
+ }
+ }
+
+ WatchAndLoadMyEmojiList(accountViewModel)
+
+ Scaffold(
+ topBar = {
+ PostingTopBar(
+ titleRes = R.string.public_message,
+ isActive = postViewModel::canPost,
+ onCancel = {
+ // uses the accountViewModel scope to avoid cancelling this
+ // function when the postViewModel is released
+ accountViewModel.runIOCatching {
+ postViewModel.sendDraftSync()
+ delay(100)
+ nav.popBack()
+ postViewModel.cancel()
+ }
+ },
+ onPost = {
+ // uses the accountViewModel scope to avoid cancelling this
+ // function when the postViewModel is released
+ accountViewModel.runIOCatching {
+ postViewModel.sendPostSync()
+ nav.popBack()
+ postViewModel.cancel()
+ }
+ nav.popBack()
+ },
+ )
+ },
+ ) { pad ->
+ Surface(
+ modifier =
+ Modifier
+ .padding(pad)
+ .consumeWindowInsets(pad)
+ .imePadding(),
+ ) {
+ PublicMessageScreenContent(postViewModel, accountViewModel, nav)
+ }
+ }
+}
+
+@Composable
+fun PublicMessageScreenContent(
+ postViewModel: NewPublicMessageViewModel,
+ accountViewModel: AccountViewModel,
+ nav: INav,
+) {
+ val scrollState = rememberScrollState()
+
+ Column(Modifier.fillMaxSize()) {
+ Row(Modifier.fillMaxWidth().padding(horizontal = Size10dp).weight(1f)) {
+ Column(
+ Modifier.fillMaxWidth().verticalScroll(scrollState),
+ verticalArrangement = spacedBy(Size10dp),
+ ) {
+ SendDirectMessageTo(postViewModel, accountViewModel)
+
+ MessageFieldRow(postViewModel, accountViewModel)
+
+ DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav)
+
+ if (postViewModel.wantsToMarkAsSensitive) {
+ ContentSensitivityExplainer()
+ }
+
+ if (postViewModel.wantsToAddGeoHash) {
+ LocationAsHash(postViewModel)
+ }
+
+ if (postViewModel.wantsForwardZapTo) {
+ ForwardZapTo(postViewModel, accountViewModel)
+ }
+
+ postViewModel.multiOrchestrator?.let {
+ Row(
+ verticalAlignment = CenterVertically,
+ modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
+ ) {
+ val context = LocalContext.current
+ ImageVideoDescription(
+ it,
+ accountViewModel.account.settings.defaultFileServer,
+ onAdd = { alt, server, sensitiveContent, mediaQuality ->
+ postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context)
+ if (server.type != ServerType.NIP95) {
+ accountViewModel.account.settings.changeDefaultFileServer(server)
+ }
+ },
+ onDelete = postViewModel::deleteMediaToUpload,
+ onCancel = { postViewModel.multiOrchestrator = null },
+ accountViewModel = accountViewModel,
+ )
+ }
+ }
+
+ if (postViewModel.wantsInvoice) {
+ NewPostInvoiceRequest(
+ onSuccess = {
+ postViewModel.insertAtCursor(it)
+ postViewModel.wantsInvoice = false
+ },
+ accountViewModel,
+ )
+ }
+
+ if (postViewModel.wantsSecretEmoji) {
+ SecretEmojiRequest {
+ postViewModel.insertAtCursor(it)
+ postViewModel.wantsSecretEmoji = false
+ }
+ }
+
+ if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) {
+ ZapRaiserRequest(
+ stringRes(id = R.string.zapraiser),
+ postViewModel,
+ )
+ }
+ }
+ }
+
+ postViewModel.userSuggestions?.let {
+ ShowUserSuggestionList(
+ it,
+ postViewModel::autocompleteWithUser,
+ accountViewModel,
+ Modifier.heightIn(0.dp, 300.dp),
+ )
+ }
+
+ postViewModel.emojiSuggestions?.let {
+ ShowEmojiSuggestionList(
+ it,
+ postViewModel::autocompleteWithEmoji,
+ postViewModel::autocompleteWithEmojiUrl,
+ accountViewModel,
+ Modifier.heightIn(0.dp, 300.dp),
+ )
+ }
+
+ BottomRowActions(postViewModel, accountViewModel)
+ }
+}
+
+@Composable
+private fun BottomRowActions(
+ postViewModel: NewPublicMessageViewModel,
+ accountViewModel: AccountViewModel,
+) {
+ val scrollState = rememberScrollState()
+ Row(
+ modifier =
+ Modifier
+ .horizontalScroll(scrollState)
+ .fillMaxWidth()
+ .height(50.dp),
+ verticalAlignment = CenterVertically,
+ ) {
+ SelectFromGallery(
+ isUploading = postViewModel.isUploadingImage,
+ tint = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier,
+ ) {
+ postViewModel.selectImage(it)
+ }
+
+ TakePictureButton(
+ onPictureTaken = {
+ postViewModel.selectImage(it)
+ },
+ )
+
+ ForwardZapToButton(postViewModel.wantsForwardZapTo) {
+ postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
+ }
+
+ if (postViewModel.canAddZapRaiser) {
+ AddZapraiserButton(postViewModel.wantsZapraiser) {
+ postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
+ }
+ }
+
+ MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) {
+ postViewModel.toggleMarkAsSensitive()
+ }
+
+ AddGeoHashButton(postViewModel.wantsToAddGeoHash) {
+ postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
+ }
+
+ AddSecretEmojiButton(postViewModel.wantsSecretEmoji) {
+ postViewModel.wantsSecretEmoji = !postViewModel.wantsSecretEmoji
+ }
+
+ if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) {
+ AddLnInvoiceButton(postViewModel.wantsInvoice) {
+ postViewModel.wantsInvoice = !postViewModel.wantsInvoice
+ }
+ }
+ }
+}
+
+@Composable
+fun SendDirectMessageTo(
+ postViewModel: NewPublicMessageViewModel,
+ accountViewModel: AccountViewModel,
+) {
+ val focusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ LaunchedEffect(Unit) {
+ launch {
+ delay(200)
+ focusRequester.requestFocus()
+ }
+ }
+
+ Column(Modifier.fillMaxWidth()) {
+ Row(
+ verticalAlignment = CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = stringRes(R.string.messages_new_message_to),
+ fontSize = Font14SP,
+ fontWeight = FontWeight.W500,
+ )
+
+ ThinPaddingTextField(
+ value = postViewModel.toUsers,
+ onValueChange = postViewModel::updateToUsers,
+ modifier =
+ Modifier
+ .weight(1f)
+ .focusRequester(focusRequester)
+ .onFocusChanged {
+ if (it.isFocused) {
+ keyboardController?.show()
+ }
+ },
+ placeholder = {
+ Text(
+ text = stringRes(R.string.messages_new_message_to_caption),
+ color = MaterialTheme.colorScheme.placeholderText,
+ )
+ },
+ visualTransformation =
+ UrlUserTagTransformation(
+ MaterialTheme.colorScheme.primary,
+ ),
+ colors =
+ OutlinedTextFieldDefaults.colors(
+ unfocusedBorderColor = Color.Transparent,
+ focusedBorderColor = Color.Transparent,
+ ),
+ )
+ }
+
+ HorizontalDivider(thickness = DividerThickness)
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt
new file mode 100644
index 000000000..f42093791
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt
@@ -0,0 +1,644 @@
+/**
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages
+
+import android.content.Context
+import android.util.Log
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.compose.currentWord
+import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
+import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.model.LocalCache
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.model.User
+import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState
+import com.vitorpamplona.amethyst.service.location.LocationState
+import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
+import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
+import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
+import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
+import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
+import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
+import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
+import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
+import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
+import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
+import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
+import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState
+import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
+import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser
+import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField
+import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder
+import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
+import com.vitorpamplona.amethyst.ui.stringRes
+import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
+import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
+import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
+import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag
+import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
+import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
+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.references.references
+import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
+import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
+import com.vitorpamplona.quartz.nip10Notes.content.findURLs
+import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
+import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
+import com.vitorpamplona.quartz.nip19Bech32.toNpub
+import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
+import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
+import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
+import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
+import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
+import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
+import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
+import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
+import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
+import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
+import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
+import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
+import com.vitorpamplona.quartz.nip92IMeta.imetas
+import com.vitorpamplona.quartz.nip94FileMetadata.alt
+import com.vitorpamplona.quartz.nip94FileMetadata.blurhash
+import com.vitorpamplona.quartz.nip94FileMetadata.dims
+import com.vitorpamplona.quartz.nip94FileMetadata.hash
+import com.vitorpamplona.quartz.nip94FileMetadata.magnet
+import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
+import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
+import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
+import com.vitorpamplona.quartz.nip94FileMetadata.size
+import com.vitorpamplona.quartz.utils.Hex
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlin.collections.plus
+
+@Stable
+class NewPublicMessageViewModel :
+ ViewModel(),
+ ILocationGrabber,
+ IMessageField,
+ IZapField,
+ IZapRaiser {
+ val draftTag = DraftTagState()
+
+ lateinit var accountViewModel: AccountViewModel
+ lateinit var account: Account
+
+ init {
+ viewModelScope.launch(Dispatchers.IO) {
+ draftTag.versions.collectLatest {
+ // don't save the first
+ if (it > 0) {
+ sendDraftSync()
+ }
+ }
+ }
+ }
+
+ val iMetaAttachments = IMetaAttachments()
+ var nip95attachments by mutableStateOf>>(emptyList())
+
+ override var message by mutableStateOf(TextFieldValue(""))
+
+ val urlPreviews = PreviewState()
+
+ var isUploadingImage by mutableStateOf(false)
+
+ var userSuggestions: UserSuggestionState? = null
+ var userSuggestionsMainMessage: UserSuggestionAnchor? = null
+
+ var emojiSuggestions: EmojiSuggestionState? = null
+
+ var toUsers by mutableStateOf(TextFieldValue(""))
+
+ // Images and Videos
+ var multiOrchestrator by mutableStateOf(null)
+
+ // Invoices
+ var canAddInvoice by mutableStateOf(false)
+ var wantsInvoice by mutableStateOf(false)
+
+ var wantsSecretEmoji by mutableStateOf(false)
+
+ // Forward Zap to
+ var wantsForwardZapTo by mutableStateOf(false)
+ override val forwardZapTo = mutableStateOf>(SplitBuilder())
+ override val forwardZapToEditting = mutableStateOf(TextFieldValue(""))
+
+ // NSFW, Sensitive
+ var wantsToMarkAsSensitive by mutableStateOf(false)
+
+ // GeoHash
+ var wantsToAddGeoHash by mutableStateOf(false)
+ var location: StateFlow? = null
+
+ // ZapRaiser
+ var canAddZapRaiser by mutableStateOf(false)
+ var wantsZapraiser by mutableStateOf(false)
+ override var zapRaiserAmount = mutableStateOf(null)
+
+ fun lnAddress(): String? = account.userProfile().info?.lnAddress()
+
+ fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null
+
+ fun user(): User? = account.userProfile()
+
+ fun init(accountVM: AccountViewModel) {
+ this.accountViewModel = accountVM
+ this.account = accountVM.account
+ this.canAddInvoice = hasLnAddress()
+ this.canAddZapRaiser = hasLnAddress()
+
+ this.userSuggestions?.reset()
+ this.userSuggestions = UserSuggestionState(accountVM)
+
+ this.emojiSuggestions?.reset()
+ this.emojiSuggestions = EmojiSuggestionState(accountVM)
+ }
+
+ fun load(users: Set) {
+ val userSet = users - account.userProfile().pubkeyHex
+
+ toUsers =
+ TextFieldValue(
+ userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
+ )
+ }
+
+ fun quote(quote: Note) {
+ message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
+ urlPreviews.update(message)
+
+ // creates a split with that author.
+ val quotedAuthor = quote.author ?: return
+
+ if (quotedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
+ if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedAuthor.pubkeyHex }) {
+ forwardZapTo.value.addItem(quotedAuthor)
+ }
+ if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
+ forwardZapTo.value.addItem(accountViewModel.userProfile())
+ }
+
+ val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedAuthor.pubkeyHex }
+ forwardZapTo.value.updatePercentage(pos, 0.9f)
+
+ wantsForwardZapTo = true
+ }
+ }
+
+ fun editFromDraft(draft: Note) {
+ val noteEvent = draft.event
+ val noteAuthor = draft.author
+
+ if (noteEvent is DraftEvent && noteAuthor != null) {
+ viewModelScope.launch(Dispatchers.IO) {
+ accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
+ val oldTag = (draft.event as? AddressableEvent)?.dTag()
+ if (oldTag != null) {
+ draftTag.set(oldTag)
+ }
+ loadFromDraft(innerNote)
+ }
+ }
+ }
+ }
+
+ private fun loadFromDraft(draft: Note) {
+ val draftEvent = draft.event as? PublicMessageEvent ?: return
+
+ val localForwardZapTo = draftEvent.tags.zapSplitSetup()
+ val totalWeight = localForwardZapTo.sumOf { it.weight }
+ forwardZapTo.value = SplitBuilder()
+ localForwardZapTo.forEach {
+ if (it is ZapSplitSetup) {
+ val user = LocalCache.getOrCreateUser(it.pubKeyHex)
+ forwardZapTo.value.addItem(user, (it.weight / totalWeight).toFloat())
+ }
+ // don't support editing old-style splits.
+ }
+ forwardZapToEditting.value = TextFieldValue("")
+ wantsForwardZapTo = localForwardZapTo.isNotEmpty()
+
+ wantsToMarkAsSensitive = draftEvent.isSensitive()
+
+ val geohash = draftEvent.getGeoHash()
+ wantsToAddGeoHash = geohash != null
+
+ val zapraiser = draftEvent.zapraiserAmount()
+ wantsZapraiser = zapraiser != null
+ zapRaiserAmount.value = null
+ if (zapraiser != null) {
+ zapRaiserAmount.value = zapraiser
+ }
+
+ if (forwardZapTo.value.items.isNotEmpty()) {
+ wantsForwardZapTo = true
+ }
+
+ val userSet = draftEvent.groupKeys() - account.userProfile().pubkeyHex
+
+ toUsers =
+ TextFieldValue(
+ userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
+ )
+
+ message = TextFieldValue(draftEvent.content)
+ urlPreviews.update(message)
+
+ iMetaAttachments.addAll(draftEvent.imetas())
+ }
+
+ override fun locationFlow(): StateFlow {
+ if (location == null) {
+ location = locationManager().geohashStateFlow
+ }
+
+ return location!!
+ }
+
+ suspend fun sendPostSync() {
+ val template = createTemplate() ?: return
+ val extraNotesToBroadcast = mutableListOf()
+
+ if (nip95attachments.isNotEmpty()) {
+ val usedImages = template.tags.taggedQuoteIds().toSet()
+ nip95attachments.forEach {
+ if (usedImages.contains(it.second.id)) {
+ extraNotesToBroadcast.add(it.first)
+ extraNotesToBroadcast.add(it.second)
+ }
+ }
+ }
+
+ accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
+ accountViewModel.deleteDraft(draftTag.current)
+
+ cancel()
+ }
+
+ suspend fun sendDraftSync() {
+ val accountViewModel = accountViewModel
+
+ if (message.text.isBlank()) {
+ accountViewModel.account.deleteDraft(draftTag.current)
+ } else {
+ val template = createTemplate() ?: return
+ accountViewModel.account.createAndSendDraft(draftTag.current, template)
+
+ nip95attachments.forEach {
+ account.sendToPrivateOutboxAndLocal(it.first)
+ account.sendToPrivateOutboxAndLocal(it.second)
+ }
+ }
+ }
+
+ private suspend fun createTemplate(): EventTemplate? {
+ val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text, null, null, accountViewModel)
+ toUsersTagger.run()
+
+ val tagger = NewMessageTagger(message.text, null, null, accountViewModel)
+ tagger.run()
+
+ val users = (toUsersTagger.pTags ?: emptyList()) + (tagger.pTags ?: emptyList())
+ val toUsers = users.mapTo(mutableSetOf()) { ReceiverTag(it.pubkeyHex, it.bestRelayHint()) }
+
+ val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null
+
+ val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
+ val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
+
+ val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
+ val urls = findURLs(tagger.message)
+ val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
+
+ val contentWarningReason = if (wantsToMarkAsSensitive) "" else null
+
+ return PublicMessageEvent.build(
+ to = toUsers.toList(),
+ msg = tagger.message,
+ ) {
+ hashtags(findHashtags(tagger.message))
+ references(findURLs(tagger.message))
+ quotes(findNostrUris(tagger.message))
+
+ geoHash?.let { geohash(it) }
+ localZapRaiserAmount?.let { zapraiser(it) }
+ zapReceiver?.let { zapSplits(it) }
+ contentWarningReason?.let { contentWarning(it) }
+
+ emojis(emojis)
+ imetas(usedAttachments)
+ }
+ }
+
+ fun findEmoji(
+ message: String,
+ myEmojiSet: List?,
+ ): List {
+ if (myEmojiSet == null) return emptyList()
+ return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
+ myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) }
+ }
+ }
+
+ fun upload(
+ alt: String?,
+ contentWarningReason: String?,
+ mediaQuality: Int,
+ server: ServerName,
+ onError: (title: String, message: String) -> Unit,
+ context: Context,
+ ) = try {
+ uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context)
+ } catch (e: SignerExceptions.ReadOnlyException) {
+ onError(
+ stringRes(context, R.string.read_only_user),
+ stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
+ )
+ }
+
+ fun uploadUnsafe(
+ alt: String?,
+ contentWarningReason: String?,
+ mediaQuality: Int,
+ server: ServerName,
+ onError: (title: String, message: String) -> Unit,
+ context: Context,
+ ) {
+ viewModelScope.launch(Dispatchers.Default) {
+ val myMultiOrchestrator = multiOrchestrator ?: return@launch
+
+ isUploadingImage = true
+
+ val results =
+ myMultiOrchestrator.upload(
+ alt,
+ contentWarningReason,
+ MediaCompressor.intToCompressorQuality(mediaQuality),
+ server,
+ account,
+ context,
+ )
+
+ if (results.allGood) {
+ results.successful.forEach { state ->
+ if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
+ val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
+ nip95attachments = nip95attachments + nip95
+ val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
+
+ note?.let {
+ message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
+ urlPreviews.update(message)
+ }
+ } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
+ val iMeta =
+ IMetaTagBuilder(state.result.url)
+ .apply {
+ hash(state.result.fileHeader.hash)
+ size(state.result.fileHeader.size)
+ state.result.fileHeader.mimeType
+ ?.let { mimeType(it) }
+ state.result.fileHeader.dim
+ ?.let { dims(it) }
+ state.result.fileHeader.blurHash
+ ?.let { blurhash(it.blurhash) }
+ state.result.magnet?.let { magnet(it) }
+ state.result.uploadedHash?.let { originalHash(it) }
+
+ alt?.let { alt(it) }
+ contentWarningReason?.let { sensitiveContent(contentWarningReason) }
+ }.build()
+
+ iMetaAttachments.replace(iMeta.url, iMeta)
+
+ message = message.insertUrlAtCursor(state.result.url)
+ urlPreviews.update(message)
+ }
+ }
+
+ multiOrchestrator = null
+ } else {
+ val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
+ onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
+ }
+
+ isUploadingImage = false
+ }
+ }
+
+ fun cancel() {
+ toUsers = TextFieldValue("")
+ message = TextFieldValue("")
+ multiOrchestrator = null
+
+ wantsInvoice = false
+ wantsZapraiser = false
+ zapRaiserAmount.value = null
+
+ wantsForwardZapTo = false
+ wantsToMarkAsSensitive = false
+ wantsToAddGeoHash = false
+ wantsSecretEmoji = false
+
+ forwardZapTo.value = SplitBuilder()
+ forwardZapToEditting.value = TextFieldValue("")
+
+ urlPreviews.reset()
+
+ userSuggestions?.reset()
+ userSuggestionsMainMessage = null
+
+ iMetaAttachments.reset()
+
+ emojiSuggestions?.reset()
+
+ draftTag.rotate()
+ }
+
+ fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
+ this.multiOrchestrator?.remove(selected)
+ }
+
+ fun addToMessage(it: String) {
+ updateMessage(TextFieldValue(message.text + " " + it))
+ }
+
+ override fun updateMessage(newMessage: TextFieldValue) {
+ message = newMessage
+ urlPreviews.update(newMessage)
+
+ if (message.selection.collapsed) {
+ userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
+
+ val lastWord = message.currentWord()
+ userSuggestions?.processCurrentWord(lastWord)
+ emojiSuggestions?.processCurrentWord(lastWord)
+ }
+
+ draftTag.newVersion()
+ }
+
+ fun updateToUsers(newToUsersValue: TextFieldValue) {
+ toUsers = newToUsersValue
+
+ if (newToUsersValue.selection.collapsed) {
+ val lastWord = newToUsersValue.currentWord()
+ userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
+ userSuggestions?.processCurrentWord(lastWord)
+ }
+
+ draftTag.newVersion()
+ }
+
+ override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) {
+ forwardZapToEditting.value = newZapForwardTo
+ if (newZapForwardTo.selection.collapsed) {
+ val lastWord = newZapForwardTo.text
+ userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS
+ userSuggestions?.processCurrentWord(lastWord)
+ }
+ }
+
+ fun autocompleteWithUser(item: User) {
+ userSuggestions?.let { userSuggestions ->
+ if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
+ val lastWord = message.currentWord()
+ message = userSuggestions.replaceCurrentWord(message, lastWord, item)
+ urlPreviews.update(message)
+ } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
+ forwardZapTo.value.addItem(item)
+ forwardZapToEditting.value = TextFieldValue("")
+ } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
+ val lastWord = toUsers.currentWord()
+ toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
+ }
+
+ userSuggestionsMainMessage = null
+ userSuggestions.reset()
+ }
+
+ draftTag.newVersion()
+ }
+
+ fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
+ val wordToInsert = ":${item.code}:"
+
+ message = message.replaceCurrentWord(wordToInsert)
+ urlPreviews.update(message)
+
+ emojiSuggestions?.reset()
+
+ draftTag.newVersion()
+ }
+
+ fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) {
+ val wordToInsert = item.link.url + " "
+
+ viewModelScope.launch(Dispatchers.IO) {
+ iMetaAttachments.downloadAndPrepare(item.link.url) {
+ Amethyst.instance.okHttpClients.getHttpClient(accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url))
+ }
+ }
+
+ message = message.replaceCurrentWord(wordToInsert)
+ urlPreviews.update(message)
+
+ emojiSuggestions?.reset()
+
+ draftTag.newVersion()
+ }
+
+ fun canPost(): Boolean =
+ message.text.isNotBlank() &&
+ !isUploadingImage &&
+ !wantsInvoice &&
+ (!wantsZapraiser || zapRaiserAmount.value != null) &&
+ (toUsers.text.isNotBlank()) &&
+ multiOrchestrator == null
+
+ fun insertAtCursor(newElement: String) {
+ message = message.insertUrlAtCursor(newElement)
+ }
+
+ fun selectImage(uris: ImmutableList) {
+ multiOrchestrator = MultiOrchestrator(uris)
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
+ }
+
+ override fun updateZapPercentage(
+ index: Int,
+ sliderValue: Float,
+ ) {
+ forwardZapTo.value.updatePercentage(index, sliderValue)
+
+ draftTag.newVersion()
+ }
+
+ override fun updateZapFromText() {
+ viewModelScope.launch(Dispatchers.Default) {
+ val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
+ tagger.run()
+ tagger.pTags?.forEach { taggedUser ->
+ if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
+ forwardZapTo.value.addItem(taggedUser)
+ }
+ }
+ }
+ }
+
+ override fun updateZapRaiserAmount(newAmount: Long?) {
+ zapRaiserAmount.value = newAmount
+ draftTag.newVersion()
+ }
+
+ fun toggleMarkAsSensitive() {
+ wantsToMarkAsSensitive = !wantsToMarkAsSensitive
+ draftTag.newVersion()
+ }
+
+ override fun locationManager(): LocationState = Amethyst.instance.locationManager
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt
index ddef713b3..8cfa16a10 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
+import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
class UserProfileConversationsFeedFilter(
val user: User,
@@ -65,6 +66,7 @@ class UserProfileConversationsFeedFilter(
it.event is ChannelMessageEvent ||
it.event is LiveActivitiesChatMessageEvent ||
it.event is CommentEvent ||
+ it.event is VoiceReplyEvent ||
it.event is TorrentCommentEvent
) &&
!it.isNewThread() &&
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt
index 24e306865..05f1534b0 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt
@@ -36,6 +36,8 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
+import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
+import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
val UserProfilePostKinds1 =
listOf(
@@ -47,6 +49,7 @@ val UserProfilePostKinds1 =
PollNoteEvent.KIND,
HighlightEvent.KIND,
WikiNoteEvent.KIND,
+ VoiceEvent.KIND,
)
val UserProfilePostKinds2 =
@@ -55,6 +58,7 @@ val UserProfilePostKinds2 =
TorrentCommentEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
CommentEvent.KIND,
+ VoiceReplyEvent.KIND,
)
fun filterUserProfilePosts(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt
index 8e79370d7..0678a0564 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt
@@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
+import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
class UserProfileNewThreadFeedFilter(
val user: User,
@@ -79,6 +80,7 @@ class UserProfileNewThreadFeedFilter(
it.event is InteractiveStoryPrologueEvent ||
it.event is AudioTrackEvent ||
it.event is AudioHeaderEvent ||
+ it.event is VoiceEvent ||
it.event is TorrentEvent
) &&
it.isNewThread() &&
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 3ccb82c9b..b6517ec19 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -159,6 +159,10 @@
Video saved to the phone\'s video gallery
Failed to save the video
Upload Image
+ Take a picture
+ Record a message
+ Record a message
+ Click and hold to record a message
Uploading…
User does not have a lightning address set up to receive sats
"reply here.. "
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/AudioMeta.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/AudioMeta.kt
index c74d41ff6..8d035e720 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/AudioMeta.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/AudioMeta.kt
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag
data class AudioMeta(
val url: String,
+ val mimeType: String? = null,
val hash: String? = null,
val duration: Int? = null,
val waveform: List? = null,
@@ -33,6 +34,7 @@ data class AudioMeta(
fun toIMetaArray(): Array =
IMetaTagBuilder(url)
.apply {
+ mimeType?.let { mimeType(it) }
hash?.let { hash(it) }
duration?.let { duration(it) }
waveform?.let { waveform(it) }
@@ -42,10 +44,11 @@ data class AudioMeta(
companion object {
fun parse(iMeta: IMetaTag): AudioMeta =
AudioMeta(
- iMeta.url,
- iMeta.hash()?.firstOrNull(),
- iMeta.duration()?.firstOrNull()?.toIntOrNull(),
- iMeta.waveform()?.firstOrNull()?.let { WaveformTag.parseWave(it) },
+ url = iMeta.url,
+ mimeType = iMeta.mimeType()?.firstOrNull(),
+ hash = iMeta.hash()?.firstOrNull(),
+ duration = iMeta.duration()?.firstOrNull()?.toIntOrNull(),
+ waveform = iMeta.waveform()?.firstOrNull()?.let { WaveformTag.parseWave(it) },
)
}
}
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/BaseVoiceEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/BaseVoiceEvent.kt
index 6fb98c5b9..d6689ad5f 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/BaseVoiceEvent.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/BaseVoiceEvent.kt
@@ -50,6 +50,7 @@ open class BaseVoiceEvent(
initializer: TagArrayBuilder.() -> Unit = {},
) = eventTemplate(kind, voiceMessage.url, createdAt) {
alt(alt)
+ audioIMeta(voiceMessage)
initializer()
}
}
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagBuilderExt.kt
index 226d110ec..4562d5275 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagBuilderExt.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagBuilderExt.kt
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.HashSha256Tag
+import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.MimeTypeTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag
/**
@@ -34,3 +35,5 @@ fun IMetaTagBuilder.hash(hash: HexKey) = add(HashSha256Tag.TAG_NAME, hash)
fun IMetaTagBuilder.duration(size: Int) = add(DurationTag.TAG_NAME, size.toString())
fun IMetaTagBuilder.waveform(wave: List) = add(WaveformTag.TAG_NAME, WaveformTag.assembleWave(wave))
+
+fun IMetaTagBuilder.mimeType(mime: String) = add(MimeTypeTag.TAG_NAME, mime)
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagExt.kt
index d65ab34e9..370f830f0 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagExt.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/IMetaTagExt.kt
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nipA0VoiceMessages
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.HashSha256Tag
+import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.MimeTypeTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag
fun IMetaTag.hash() = properties.get(HashSha256Tag.TAG_NAME)
@@ -30,3 +31,5 @@ fun IMetaTag.hash() = properties.get(HashSha256Tag.TAG_NAME)
fun IMetaTag.duration() = properties.get(DurationTag.TAG_NAME)
fun IMetaTag.waveform() = properties.get(WaveformTag.TAG_NAME)
+
+fun IMetaTag.mimeType() = properties.get(MimeTypeTag.TAG_NAME)
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt
index 1854a71bf..f482eb201 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt
@@ -20,15 +20,21 @@
*/
package com.vitorpamplona.quartz.nipA0VoiceMessages
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
+import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag
+import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag
+import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyKindTag
fun TagArrayBuilder.audioIMeta(
url: String,
+ mimeType: String? = null,
hash: String? = null,
duration: Int? = null,
waveform: List? = null,
-) = audioIMeta(AudioMeta(url, hash, duration, waveform))
+) = audioIMeta(AudioMeta(url, mimeType, hash, duration, waveform))
fun TagArrayBuilder.audioIMeta(imeta: AudioMeta): TagArrayBuilder {
add(imeta.toIMetaArray())
@@ -40,3 +46,18 @@ fun TagArrayBuilder.audioIMeta(audioUrls: List.replyEvent(
+ eventId: String,
+ relayHint: NormalizedRelayUrl?,
+ pubkey: String?,
+) = addUnique(ReplyEventTag.assemble(eventId, relayHint, pubkey))
+
+fun TagArrayBuilder.replyKind(kind: String) = addUnique(ReplyKindTag.assemble(kind))
+
+fun TagArrayBuilder.replyKind(kind: Int) = addUnique(ReplyKindTag.assemble(kind))
+
+fun TagArrayBuilder.replyAuthor(
+ pubKey: HexKey,
+ relay: NormalizedRelayUrl?,
+) = add(ReplyAuthorTag.assemble(pubKey, relay))
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceEvent.kt
index a43185712..33f30d596 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceEvent.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceEvent.kt
@@ -38,6 +38,14 @@ class VoiceEvent(
const val KIND = 1222
const val ALT_DESCRIPTION = "Voice message"
+ fun build(
+ url: String,
+ mimeType: String?,
+ hash: String,
+ duration: Int,
+ waveform: List,
+ ) = build(AudioMeta(url, mimeType, hash, duration, waveform))
+
fun build(
voiceMessage: AudioMeta,
createdAt: Long = TimeUtils.now(),
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt
index 5d43098cd..eb7444881 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt
@@ -23,7 +23,12 @@ package com.vitorpamplona.quartz.nipA0VoiceMessages
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
+import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
+import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag
+import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag
+import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyKindTag
import com.vitorpamplona.quartz.utils.TimeUtils
+import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull
@Immutable
class VoiceReplyEvent(
@@ -34,14 +39,45 @@ class VoiceReplyEvent(
content: String,
sig: HexKey,
) : BaseVoiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
+ fun replyAuthor() = tags.firstNotNullOfOrNull(ReplyAuthorTag::parse)
+
+ fun replyAuthors() = tags.filter(ReplyAuthorTag::match)
+
+ fun replyAuthorKeys() = tags.mapNotNull(ReplyAuthorTag::parseKey)
+
+ fun replyAuthorHints() = tags.mapNotNull(ReplyAuthorTag::parseAsHint)
+
+ fun directReplies() = tags.filter { ReplyEventTag.match(it) }
+
+ fun directKinds() = tags.filter(ReplyKindTag::match)
+
+ fun markedReplyTos(): List = tags.mapNotNull(ReplyEventTag::parseKey)
+
+ fun replyingTo(): HexKey? = tags.lastNotNullOfOrNull(ReplyEventTag::parseKey)
+
companion object {
const val KIND = 1244
const val ALT_DESCRIPTION = "Voice reply"
+ fun build(
+ url: String,
+ mimeType: String?,
+ hash: String,
+ duration: Int,
+ waveform: List,
+ replyingTo: EventHintBundle,
+ ) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo)
+
fun build(
voiceMessage: AudioMeta,
+ replyingTo: EventHintBundle,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder.() -> Unit = {},
- ) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt, initializer)
+ ) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt) {
+ replyEvent(replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey)
+ replyKind(replyingTo.event.kind)
+ replyAuthor(replyingTo.event.pubKey, replyingTo.authorHomeRelay)
+ initializer()
+ }
}
}
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/MimeTypeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/MimeTypeTag.kt
new file mode 100644
index 000000000..2dc30e039
--- /dev/null
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/MimeTypeTag.kt
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.nipA0VoiceMessages.tags
+
+import com.vitorpamplona.quartz.nip01Core.core.has
+import com.vitorpamplona.quartz.utils.ensure
+
+class MimeTypeTag {
+ companion object {
+ const val TAG_NAME = "m"
+
+ fun isIn(
+ tag: Array,
+ mimeTypes: Set,
+ ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in mimeTypes
+
+ @JvmStatic
+ fun parse(tag: Array): String? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].isNotEmpty()) { return null }
+ return tag[1]
+ }
+
+ @JvmStatic
+ fun assemble(mimeType: String) = arrayOf(TAG_NAME, mimeType)
+ }
+}
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyAuthorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyAuthorTag.kt
new file mode 100644
index 000000000..5853f5f7e
--- /dev/null
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyAuthorTag.kt
@@ -0,0 +1,85 @@
+/**
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.nipA0VoiceMessages.tags
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.Tag
+import com.vitorpamplona.quartz.nip01Core.core.has
+import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
+import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag
+import com.vitorpamplona.quartz.utils.arrayOfNotNull
+import com.vitorpamplona.quartz.utils.ensure
+
+@Immutable
+data class ReplyAuthorTag(
+ override val pubKey: HexKey,
+ override val relayHint: NormalizedRelayUrl? = null,
+) : PubKeyReferenceTag {
+ fun toTagArray() = assemble(pubKey, relayHint)
+
+ companion object {
+ const val TAG_NAME = "p"
+
+ @JvmStatic
+ fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
+
+ @JvmStatic
+ fun parse(tag: Tag): ReplyAuthorTag? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].length == 64) { return null }
+
+ val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
+
+ return ReplyAuthorTag(tag[1], hint)
+ }
+
+ @JvmStatic
+ fun parseKey(tag: Tag): HexKey? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].length == 64) { return null }
+ return tag[1]
+ }
+
+ @JvmStatic
+ fun parseAsHint(tag: Array): PubKeyHint? {
+ ensure(tag.has(2)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].length == 64) { return null }
+ ensure(tag[2].isNotEmpty()) { return null }
+
+ val hint = RelayUrlNormalizer.normalizeOrNull(tag[2])
+ ensure(hint != null) { return null }
+
+ return PubKeyHint(tag[1], hint)
+ }
+
+ @JvmStatic
+ fun assemble(
+ pubkey: HexKey,
+ relayHint: NormalizedRelayUrl?,
+ ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url)
+ }
+}
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyEventTag.kt
new file mode 100644
index 000000000..08c10fd9f
--- /dev/null
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyEventTag.kt
@@ -0,0 +1,109 @@
+/**
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.nipA0VoiceMessages.tags
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.Tag
+import com.vitorpamplona.quartz.nip01Core.core.has
+import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
+import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference
+import com.vitorpamplona.quartz.utils.Hex
+import com.vitorpamplona.quartz.utils.arrayOfNotNull
+import com.vitorpamplona.quartz.utils.ensure
+
+@Immutable
+class ReplyEventTag(
+ val ref: EventReference,
+) {
+ constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(EventReference(eventId, pubkey, relayHint))
+
+ fun toTagArray() = assemble(ref)
+
+ companion object {
+ const val TAG_NAME = "e"
+
+ @JvmStatic
+ fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
+
+ @JvmStatic
+ fun isTagged(
+ tag: Array,
+ eventId: String,
+ ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId
+
+ @JvmStatic
+ fun isIn(
+ tag: Array,
+ eventIds: Set,
+ ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in eventIds
+
+ @JvmStatic
+ fun parse(tag: Array): ReplyEventTag? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].length == 64) { return null }
+ return ReplyEventTag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, tag.getOrNull(3))
+ }
+
+ @JvmStatic
+ fun parseKey(tag: Array): String? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].length == 64) { return null }
+ return tag[1]
+ }
+
+ @JvmStatic
+ fun parseValidKey(tag: Array): String? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].length == 64) { return null }
+ ensure(Hex.isHex(tag[1])) { return null }
+ return tag[1]
+ }
+
+ @JvmStatic
+ fun parseAsHint(tag: Array): EventIdHint? {
+ ensure(tag.has(2)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].length == 64) { return null }
+ ensure(tag[2].isNotEmpty()) { return null }
+
+ val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2])
+ ensure(relayHint != null) { return null }
+
+ return EventIdHint(tag[1], relayHint)
+ }
+
+ @JvmStatic
+ fun assemble(
+ eventId: HexKey,
+ relay: NormalizedRelayUrl?,
+ pubkey: String?,
+ ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey)
+
+ @JvmStatic
+ fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author)
+ }
+}
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyKindTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyKindTag.kt
new file mode 100644
index 000000000..7ecb255b7
--- /dev/null
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipA0VoiceMessages/tags/ReplyKindTag.kt
@@ -0,0 +1,76 @@
+/**
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.nipA0VoiceMessages.tags
+
+import com.vitorpamplona.quartz.nip01Core.core.Tag
+import com.vitorpamplona.quartz.nip01Core.core.has
+import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
+import com.vitorpamplona.quartz.utils.ensure
+
+class ReplyKindTag {
+ companion object {
+ const val TAG_NAME = "k"
+
+ @JvmStatic
+ fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
+
+ @JvmStatic
+ fun isKind(
+ tag: Tag,
+ kind: String,
+ ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind
+
+ @JvmStatic
+ fun isTagged(
+ tag: Tag,
+ kind: String,
+ ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind
+
+ @JvmStatic
+ fun isIn(
+ tag: Tag,
+ kinds: Set,
+ ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in kinds
+
+ @JvmStatic
+ fun parse(tag: Tag): String? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].isNotEmpty()) { return null }
+ return tag[1]
+ }
+
+ @JvmStatic
+ fun assemble(kind: String) = arrayOf(TAG_NAME, kind)
+
+ @JvmStatic
+ fun assemble(kind: Int) = arrayOf(TAG_NAME, kind.toString())
+
+ @JvmStatic
+ fun assemble(id: ExternalId) = assemble(id.toKind())
+
+ @JvmStatic
+ fun assemble(kinds: List): List = kinds.map { assemble(it) }
+
+ @JvmStatic
+ fun assemble(kinds: Set): List = kinds.map { assemble(it) }
+ }
+}