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) } + } +}