Adds support to reply to Yaks with another voice message.
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+106
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+379
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+644
@@ -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
|
||||||
|
}
|
||||||
+2
@@ -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() &&
|
||||||
|
|||||||
+4
@@ -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(
|
||||||
|
|||||||
+2
@@ -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() &&
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+22
-1
@@ -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(),
|
||||||
|
|||||||
+37
-1
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user