Adds support to reply to Yaks with another voice message.

This commit is contained in:
Vitor Pamplona
2025-07-29 20:07:28 -04:00
parent c9de32c3fa
commit 8abba9246c
30 changed files with 1899 additions and 10 deletions
+3
View File
@@ -23,6 +23,9 @@
<!-- To take pictures --> <!-- To take pictures -->
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<!-- To record audio messages -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- To read NFCs that contain nostr:<NIP19> --> <!-- To read NFCs that contain nostr:<NIP19> -->
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
@@ -204,6 +204,8 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent 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.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -960,6 +962,27 @@ class Account(
cache.getNoteIfExists(signedEvent.id)?.let { onReady(it) } cache.getNoteIfExists(signedEvent.id)?.let { onReady(it) }
} }
suspend fun sendVoiceMessage(
url: String,
mimeType: String?,
hash: String,
duration: Int,
waveform: List<Int>,
) {
signAndComputeBroadcast(VoiceEvent.build(url, mimeType, hash, duration, waveform))
}
suspend fun sendVoiceReplyMessage(
url: String,
mimeType: String?,
hash: String,
duration: Int,
waveform: List<Int>,
replyTo: EventHintBundle<VoiceEvent>,
) {
signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo))
}
suspend fun sendAllAsOnePictureEvent( suspend fun sendAllAsOnePictureEvent(
urlHeaderInfo: Map<String, FileHeader>, urlHeaderInfo: Map<String, FileHeader>,
caption: String?, caption: String?,
@@ -782,6 +782,8 @@ object LocalCache : ILocalCache {
is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is CommentEvent -> 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 ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
is ChatMessageEncryptedFileHeaderEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } is ChatMessageEncryptedFileHeaderEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
@@ -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<VoiceMessageRecorder?>(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,
)
}
@@ -129,7 +129,7 @@ fun PictureButton(onClick: () -> Unit) {
) { ) {
Icon( Icon(
imageVector = Icons.Default.CameraAlt, imageVector = Icons.Default.CameraAlt,
contentDescription = stringRes(id = R.string.upload_image), contentDescription = stringRes(id = R.string.take_a_picture),
modifier = Modifier.height(22.dp), modifier = Modifier.height(22.dp),
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
) )
@@ -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<Int>,
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<Int> = 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
}
}
}
@@ -20,15 +20,29 @@
*/ */
package com.vitorpamplona.amethyst.ui.components 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.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource 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.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import com.vitorpamplona.amethyst.ui.theme.ripple24dp 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<PressInteraction.Press>()
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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ClickableBox( fun ClickableBox(
@@ -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.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen 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.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.privacy.PrivacyOptionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
@@ -172,6 +173,14 @@ fun AppNavigation(
) )
} }
composableFromBottomArgs<Route.NewPublicMessage> {
NewPublicMessageScreen(
to = it.toKey(),
accountViewModel,
nav,
)
}
composableFromBottomArgs<Route.HashtagPost> { composableFromBottomArgs<Route.HashtagPost> {
HashtagPostScreen( HashtagPostScreen(
hashtag = it.hashtag, hashtag = it.hashtag,
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId 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.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -200,6 +201,7 @@ fun routeReplyTo(
): Route? { ): Route? {
val noteEvent = note.event val noteEvent = note.event
return when (noteEvent) { return when (noteEvent) {
is PublicMessageEvent -> Route.NewPublicMessage(noteEvent.groupKeySet() - account.userProfile().pubkeyHex)
is TextNoteEvent -> Route.NewPost(baseReplyTo = note.idHex) is TextNoteEvent -> Route.NewPost(baseReplyTo = note.idHex)
is PrivateDmEvent -> is PrivateDmEvent ->
routeToMessage( routeToMessage(
@@ -138,6 +138,16 @@ sealed class Route {
fun toKey(): ChatroomKey = ChatroomKey(id.split(",").toSet()) fun toKey(): ChatroomKey = ChatroomKey(id.split(",").toSet())
} }
@Serializable data class NewPublicMessage(
val to: String,
) : Route() {
constructor(users: Set<HexKey>) : this(
to = users.joinToString(","),
)
fun toKey(): Set<HexKey> = to.split(",").toSet()
}
@Serializable data class RoomByAuthor( @Serializable data class RoomByAuthor(
val id: String, val id: String,
) : Route() ) : Route()
@@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.AddReaction import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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 @Composable
fun CommentIcon( fun CommentIcon(
iconSizeModifier: Modifier, iconSizeModifier: Modifier,
@@ -93,6 +93,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.model.FeatureSetType 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.event.observeNoteZaps
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled 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.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable 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.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -578,8 +581,37 @@ private fun ReplyReactionWithDialog(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
ReplyReaction(baseNote, grayTint, accountViewModel) { if (baseNote.event is BaseVoiceEvent) {
nav.nav { routeReplyTo(baseNote, accountViewModel.account) } 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)
} }
} }
@@ -28,6 +28,7 @@ import android.util.LruCache
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope 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.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver 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.Dao
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk 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.UrlPreviewState
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
import com.vitorpamplona.amethyst.ui.feeds.FeedState 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.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent 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.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay 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.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.mapNotNullAsync import com.vitorpamplona.quartz.utils.mapNotNullAsync
@@ -1249,6 +1256,54 @@ class AccountViewModel(
super.onCleared() super.onCleared()
} }
fun sendVoiceReply(
note: Note,
recording: RecordingResult,
context: Context,
) {
if (isWriteable()) {
val hint = note.toEventHint<VoiceEvent>()
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( fun loadThumb(
context: Context, context: Context,
thumbUri: String, thumbUri: String,
@@ -1673,7 +1728,7 @@ class AccountViewModel(
is NSec -> {} is NSec -> {}
is NPub -> {} is NPub -> {}
is NProfile -> {} is NProfile -> {}
is com.vitorpamplona.quartz.nip19Bech32.entities.NNote -> { is NNote -> {
LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note ->
returningNote = note returningNote = note
} }
@@ -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<HexKey>? = 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)
}
}
@@ -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<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(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<MultiOrchestrator?>(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<User>>(SplitBuilder())
override val forwardZapToEditting = mutableStateOf(TextFieldValue(""))
// NSFW, Sensitive
var wantsToMarkAsSensitive by mutableStateOf(false)
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
var location: StateFlow<LocationState.LocationResult>? = null
// ZapRaiser
var canAddZapRaiser by mutableStateOf(false)
var wantsZapraiser by mutableStateOf(false)
override var zapRaiserAmount = mutableStateOf<Long?>(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<HexKey>) {
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<LocationState.LocationResult> {
if (location == null) {
location = locationManager().geohashStateFlow
}
return location!!
}
suspend fun sendPostSync() {
val template = createTemplate() ?: return
val extraNotesToBroadcast = mutableListOf<Event>()
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<out Event>? {
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<EmojiPackState.EmojiMedia>?,
): List<EmojiUrlTag> {
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<SelectedMedia>) {
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
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
class UserProfileConversationsFeedFilter( class UserProfileConversationsFeedFilter(
val user: User, val user: User,
@@ -65,6 +66,7 @@ class UserProfileConversationsFeedFilter(
it.event is ChannelMessageEvent || it.event is ChannelMessageEvent ||
it.event is LiveActivitiesChatMessageEvent || it.event is LiveActivitiesChatMessageEvent ||
it.event is CommentEvent || it.event is CommentEvent ||
it.event is VoiceReplyEvent ||
it.event is TorrentCommentEvent it.event is TorrentCommentEvent
) && ) &&
!it.isNewThread() && !it.isNewThread() &&
@@ -36,6 +36,8 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
val UserProfilePostKinds1 = val UserProfilePostKinds1 =
listOf( listOf(
@@ -47,6 +49,7 @@ val UserProfilePostKinds1 =
PollNoteEvent.KIND, PollNoteEvent.KIND,
HighlightEvent.KIND, HighlightEvent.KIND,
WikiNoteEvent.KIND, WikiNoteEvent.KIND,
VoiceEvent.KIND,
) )
val UserProfilePostKinds2 = val UserProfilePostKinds2 =
@@ -55,6 +58,7 @@ val UserProfilePostKinds2 =
TorrentCommentEvent.KIND, TorrentCommentEvent.KIND,
InteractiveStoryPrologueEvent.KIND, InteractiveStoryPrologueEvent.KIND,
CommentEvent.KIND, CommentEvent.KIND,
VoiceReplyEvent.KIND,
) )
fun filterUserProfilePosts( fun filterUserProfilePosts(
@@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
class UserProfileNewThreadFeedFilter( class UserProfileNewThreadFeedFilter(
val user: User, val user: User,
@@ -79,6 +80,7 @@ class UserProfileNewThreadFeedFilter(
it.event is InteractiveStoryPrologueEvent || it.event is InteractiveStoryPrologueEvent ||
it.event is AudioTrackEvent || it.event is AudioTrackEvent ||
it.event is AudioHeaderEvent || it.event is AudioHeaderEvent ||
it.event is VoiceEvent ||
it.event is TorrentEvent it.event is TorrentEvent
) && ) &&
it.isNewThread() && it.isNewThread() &&
+4
View File
@@ -159,6 +159,10 @@
<string name="video_saved_to_the_gallery">Video saved to the phone\'s video gallery</string> <string name="video_saved_to_the_gallery">Video saved to the phone\'s video gallery</string>
<string name="failed_to_save_the_video">Failed to save the video</string> <string name="failed_to_save_the_video">Failed to save the video</string>
<string name="upload_image">Upload Image</string> <string name="upload_image">Upload Image</string>
<string name="take_a_picture">Take a picture</string>
<string name="record_a_message">Record a message</string>
<string name="record_a_message_title">Record a message</string>
<string name="record_a_message_description">Click and hold to record a message</string>
<string name="uploading">Uploading…</string> <string name="uploading">Uploading…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">User does not have a lightning address set up to receive sats</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">User does not have a lightning address set up to receive sats</string>
<string name="reply_here">"reply here.. "</string> <string name="reply_here">"reply here.. "</string>
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag
data class AudioMeta( data class AudioMeta(
val url: String, val url: String,
val mimeType: String? = null,
val hash: String? = null, val hash: String? = null,
val duration: Int? = null, val duration: Int? = null,
val waveform: List<Int>? = null, val waveform: List<Int>? = null,
@@ -33,6 +34,7 @@ data class AudioMeta(
fun toIMetaArray(): Array<String> = fun toIMetaArray(): Array<String> =
IMetaTagBuilder(url) IMetaTagBuilder(url)
.apply { .apply {
mimeType?.let { mimeType(it) }
hash?.let { hash(it) } hash?.let { hash(it) }
duration?.let { duration(it) } duration?.let { duration(it) }
waveform?.let { waveform(it) } waveform?.let { waveform(it) }
@@ -42,10 +44,11 @@ data class AudioMeta(
companion object { companion object {
fun parse(iMeta: IMetaTag): AudioMeta = fun parse(iMeta: IMetaTag): AudioMeta =
AudioMeta( AudioMeta(
iMeta.url, url = iMeta.url,
iMeta.hash()?.firstOrNull(), mimeType = iMeta.mimeType()?.firstOrNull(),
iMeta.duration()?.firstOrNull()?.toIntOrNull(), hash = iMeta.hash()?.firstOrNull(),
iMeta.waveform()?.firstOrNull()?.let { WaveformTag.parseWave(it) }, duration = iMeta.duration()?.firstOrNull()?.toIntOrNull(),
waveform = iMeta.waveform()?.firstOrNull()?.let { WaveformTag.parseWave(it) },
) )
} }
} }
@@ -50,6 +50,7 @@ open class BaseVoiceEvent(
initializer: TagArrayBuilder<T>.() -> Unit = {}, initializer: TagArrayBuilder<T>.() -> Unit = {},
) = eventTemplate(kind, voiceMessage.url, createdAt) { ) = eventTemplate(kind, voiceMessage.url, createdAt) {
alt(alt) alt(alt)
audioIMeta(voiceMessage)
initializer() initializer()
} }
} }
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.HashSha256Tag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.HashSha256Tag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.MimeTypeTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag 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.duration(size: Int) = add(DurationTag.TAG_NAME, size.toString())
fun IMetaTagBuilder.waveform(wave: List<Int>) = add(WaveformTag.TAG_NAME, WaveformTag.assembleWave(wave)) fun IMetaTagBuilder.waveform(wave: List<Int>) = add(WaveformTag.TAG_NAME, WaveformTag.assembleWave(wave))
fun IMetaTagBuilder.mimeType(mime: String) = add(MimeTypeTag.TAG_NAME, mime)
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nipA0VoiceMessages
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.HashSha256Tag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.HashSha256Tag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.MimeTypeTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag
fun IMetaTag.hash() = properties.get(HashSha256Tag.TAG_NAME) 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.duration() = properties.get(DurationTag.TAG_NAME)
fun IMetaTag.waveform() = properties.get(WaveformTag.TAG_NAME) fun IMetaTag.waveform() = properties.get(WaveformTag.TAG_NAME)
fun IMetaTag.mimeType() = properties.get(MimeTypeTag.TAG_NAME)
@@ -20,15 +20,21 @@
*/ */
package com.vitorpamplona.quartz.nipA0VoiceMessages package com.vitorpamplona.quartz.nipA0VoiceMessages
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder 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.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 <T : BaseVoiceEvent> TagArrayBuilder<T>.audioIMeta( fun <T : BaseVoiceEvent> TagArrayBuilder<T>.audioIMeta(
url: String, url: String,
mimeType: String? = null,
hash: String? = null, hash: String? = null,
duration: Int? = null, duration: Int? = null,
waveform: List<Int>? = null, waveform: List<Int>? = null,
) = audioIMeta(AudioMeta(url, hash, duration, waveform)) ) = audioIMeta(AudioMeta(url, mimeType, hash, duration, waveform))
fun <T : BaseVoiceEvent> TagArrayBuilder<T>.audioIMeta(imeta: AudioMeta): TagArrayBuilder<T> { fun <T : BaseVoiceEvent> TagArrayBuilder<T>.audioIMeta(imeta: AudioMeta): TagArrayBuilder<T> {
add(imeta.toIMetaArray()) add(imeta.toIMetaArray())
@@ -40,3 +46,18 @@ fun <T : BaseVoiceEvent> TagArrayBuilder<T>.audioIMeta(audioUrls: List<AudioMeta
audioUrls.forEach { audioIMeta(it) } audioUrls.forEach { audioIMeta(it) }
return this return this
} }
fun TagArrayBuilder<VoiceReplyEvent>.replyEvent(
eventId: String,
relayHint: NormalizedRelayUrl?,
pubkey: String?,
) = addUnique(ReplyEventTag.assemble(eventId, relayHint, pubkey))
fun TagArrayBuilder<VoiceReplyEvent>.replyKind(kind: String) = addUnique(ReplyKindTag.assemble(kind))
fun TagArrayBuilder<VoiceReplyEvent>.replyKind(kind: Int) = addUnique(ReplyKindTag.assemble(kind))
fun TagArrayBuilder<VoiceReplyEvent>.replyAuthor(
pubKey: HexKey,
relay: NormalizedRelayUrl?,
) = add(ReplyAuthorTag.assemble(pubKey, relay))
@@ -38,6 +38,14 @@ class VoiceEvent(
const val KIND = 1222 const val KIND = 1222
const val ALT_DESCRIPTION = "Voice message" const val ALT_DESCRIPTION = "Voice message"
fun build(
url: String,
mimeType: String?,
hash: String,
duration: Int,
waveform: List<Int>,
) = build(AudioMeta(url, mimeType, hash, duration, waveform))
fun build( fun build(
voiceMessage: AudioMeta, voiceMessage: AudioMeta,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
@@ -23,7 +23,12 @@ package com.vitorpamplona.quartz.nipA0VoiceMessages
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder 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.TimeUtils
import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull
@Immutable @Immutable
class VoiceReplyEvent( class VoiceReplyEvent(
@@ -34,14 +39,45 @@ class VoiceReplyEvent(
content: String, content: String,
sig: HexKey, sig: HexKey,
) : BaseVoiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) { ) : 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<HexKey> = tags.mapNotNull(ReplyEventTag::parseKey)
fun replyingTo(): HexKey? = tags.lastNotNullOfOrNull(ReplyEventTag::parseKey)
companion object { companion object {
const val KIND = 1244 const val KIND = 1244
const val ALT_DESCRIPTION = "Voice reply" const val ALT_DESCRIPTION = "Voice reply"
fun build(
url: String,
mimeType: String?,
hash: String,
duration: Int,
waveform: List<Int>,
replyingTo: EventHintBundle<VoiceEvent>,
) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo)
fun build( fun build(
voiceMessage: AudioMeta, voiceMessage: AudioMeta,
replyingTo: EventHintBundle<VoiceEvent>,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<VoiceReplyEvent>.() -> Unit = {}, initializer: TagArrayBuilder<VoiceReplyEvent>.() -> 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()
}
} }
} }
@@ -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<String>,
mimeTypes: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in mimeTypes
@JvmStatic
fun parse(tag: Array<String>): 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)
}
}
@@ -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<String>): 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)
}
}
@@ -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<String>,
eventId: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId
@JvmStatic
fun isIn(
tag: Array<String>,
eventIds: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in eventIds
@JvmStatic
fun parse(tag: Array<String>): 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>): 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>): 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<String>): 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)
}
}
@@ -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<String>,
) = 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<String>): List<Tag> = kinds.map { assemble(it) }
@JvmStatic
fun assemble(kinds: Set<String>): List<Tag> = kinds.map { assemble(it) }
}
}