Merge branch 'main' into share-videos-to-other-apps

This commit is contained in:
davotoula
2026-01-20 19:35:41 +01:00
20 changed files with 310 additions and 177 deletions
@@ -42,11 +42,14 @@ import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
const val MAX_VOICE_RECORD_SECONDS = 600
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun RecordAudioBox( fun RecordAudioBox(
modifier: Modifier, modifier: Modifier,
onRecordTaken: (RecordingResult) -> Unit, onRecordTaken: (RecordingResult) -> Unit,
maxDurationSeconds: Int? = null,
content: @Composable (Boolean, Int) -> Unit, content: @Composable (Boolean, Int) -> Unit,
) { ) {
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) } val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
@@ -107,6 +110,10 @@ fun RecordAudioBox(
while (isActive) { while (isActive) {
delay(1000) delay(1000)
elapsedSeconds++ elapsedSeconds++
if (maxDurationSeconds != null && elapsedSeconds >= maxDurationSeconds) {
stopRecording()
break
}
} }
} else { } else {
// Reset elapsed time when not recording // Reset elapsed time when not recording
@@ -42,7 +42,10 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@Composable @Composable
fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { fun RecordVoiceButton(
onVoiceTaken: (RecordingResult) -> Unit,
maxDurationSeconds: Int? = null,
) {
var isRecording by remember { mutableStateOf(false) } var isRecording by remember { mutableStateOf(false) }
var elapsedSeconds by remember { mutableIntStateOf(0) } var elapsedSeconds by remember { mutableIntStateOf(0) }
@@ -61,6 +64,7 @@ fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) {
elapsedSeconds = 0 elapsedSeconds = 0
onVoiceTaken(recording) onVoiceTaken(recording)
}, },
maxDurationSeconds = maxDurationSeconds,
) { recordingState, elapsed -> ) { recordingState, elapsed ->
// Update parent state after composition completes // Update parent state after composition completes
SideEffect { SideEffect {
@@ -61,6 +61,7 @@ class VoiceAnonymizationController(
preset: VoicePreset, preset: VoicePreset,
originalFile: File?, originalFile: File?,
) { ) {
Log.d(logTag, "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}")
if (processingPreset != null || preset == selectedPreset) return if (processingPreset != null || preset == selectedPreset) return
if (preset == VoicePreset.NONE) { if (preset == VoicePreset.NONE) {
@@ -76,9 +77,9 @@ class VoiceAnonymizationController(
val file = originalFile ?: return val file = originalFile ?: return
processingJob?.cancel() processingJob?.cancel()
processingPreset = preset
processingJob = processingJob =
scope.launch { scope.launch {
processingPreset = preset
try { try {
val anonymizer = VoiceAnonymizer() val anonymizer = VoiceAnonymizer()
val result = anonymizer.anonymize(file, preset) val result = anonymizer.anonymize(file, preset)
@@ -108,8 +109,11 @@ class VoiceAnonymizationController(
distortedFiles.values.forEach { result -> distortedFiles.values.forEach { result ->
try { try {
if (result.file.exists()) { if (result.file.exists()) {
result.file.delete() if (result.file.delete()) {
Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}") Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}")
} else {
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}")
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e) Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e)
@@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
@@ -63,7 +62,7 @@ fun VoiceAnonymizationSection(
Text( Text(
text = stringRes(R.string.voice_anonymize_description), text = stringRes(R.string.voice_anonymize_description),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color.Gray, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3, maxLines = 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -41,12 +41,26 @@ import java.io.File
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.math.abs import kotlin.math.abs
/**
* Result of voice anonymization processing.
*
* @property file The output audio file (AAC in MP4 container)
* @property waveform Amplitude data for waveform visualization (one value per second)
* @property duration Audio duration in seconds
*/
data class AnonymizedResult( data class AnonymizedResult(
val file: File, val file: File,
val waveform: List<Float>, val waveform: List<Float>,
val duration: Int, val duration: Int,
) )
/**
* Processes audio files to alter voice characteristics for privacy.
*
* Uses TarsosDSP's WSOLA (Waveform Similarity Overlap-Add) algorithm combined with
* rate transposition to shift pitch while preserving duration. Note that in TarsosDSP,
* pitch factors work inversely: factor < 1 raises pitch, factor > 1 lowers pitch.
*/
class VoiceAnonymizer { class VoiceAnonymizer {
companion object { companion object {
private const val TAG = "VoiceAnonymizer" private const val TAG = "VoiceAnonymizer"
@@ -54,6 +68,20 @@ class VoiceAnonymizer {
private const val BIT_RATE = 128000 private const val BIT_RATE = 128000
} }
/**
* Applies voice anonymization to an audio file.
*
* The process involves three stages:
* 1. Decode input audio to PCM (0-30% progress)
* 2. Apply pitch shifting with TarsosDSP (30-70% progress)
* 3. Encode processed audio to AAC (70-100% progress)
*
* @param inputFile Source audio file (supports formats decodable by MediaCodec)
* @param preset Voice transformation preset (NONE is not allowed)
* @param onProgress Callback invoked with progress value from 0.0 to 1.0
* @return [Result.success] with [AnonymizedResult] containing the output file,
* waveform data, and duration; or [Result.failure] with the exception
*/
suspend fun anonymize( suspend fun anonymize(
inputFile: File, inputFile: File,
preset: VoicePreset, preset: VoicePreset,
@@ -98,7 +126,8 @@ class VoiceAnonymizer {
): File { ): File {
val baseName = inputFile.nameWithoutExtension val baseName = inputFile.nameWithoutExtension
val presetSuffix = preset.name.lowercase() val presetSuffix = preset.name.lowercase()
return File(inputFile.parentFile, "${baseName}_$presetSuffix.mp4") val parentDir = inputFile.parentFile ?: inputFile.absoluteFile.parentFile
return File(parentDir, "${baseName}_$presetSuffix.mp4")
} }
private data class DecodedAudio( private data class DecodedAudio(
@@ -129,9 +158,7 @@ class VoiceAnonymizer {
} }
} }
if (audioTrackIndex == -1 || format == null) { check(audioTrackIndex != -1 && format != null) { "No audio track found in file" }
throw IllegalStateException("No audio track found in file")
}
extractor.selectTrack(audioTrackIndex) extractor.selectTrack(audioTrackIndex)
val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm" val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm"
@@ -143,7 +170,8 @@ class VoiceAnonymizer {
decoder.configure(format, null, null, 0) decoder.configure(format, null, null, 0)
decoder.start() decoder.start()
val pcmSamples = mutableListOf<Float>() val estimatedSamples = (sampleRate.toLong() * durationUs / 1_000_000).toInt()
val pcmSamples = ArrayList<Float>(estimatedSamples)
val bufferInfo = MediaCodec.BufferInfo() val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false var inputDone = false
var outputDone = false var outputDone = false
@@ -196,7 +224,11 @@ class VoiceAnonymizer {
return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration) return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration)
} finally { } finally {
decoder?.stop() try {
decoder?.stop()
} catch (_: IllegalStateException) {
// Decoder was never started
}
decoder?.release() decoder?.release()
extractor.release() extractor.release()
} }
@@ -218,8 +250,8 @@ class VoiceAnonymizer {
} }
else -> baseFactor else -> baseFactor
} }
val processedSamples = mutableListOf<Float>()
val totalSamples = pcmData.size val totalSamples = pcmData.size
val processedSamples = ArrayList<Float>(totalSamples)
val wsola = val wsola =
WaveformSimilarityBasedOverlapAdd( WaveformSimilarityBasedOverlapAdd(
@@ -252,7 +284,9 @@ class VoiceAnonymizer {
return true return true
} }
override fun processingFinished() {} override fun processingFinished() {
// No-op: no cleanup needed
}
} }
val dispatcher = val dispatcher =
@@ -276,7 +310,9 @@ class VoiceAnonymizer {
return true return true
} }
override fun processingFinished() {} override fun processingFinished() {
// No-op: no cleanup needed
}
} }
dispatcher.addAudioProcessor(progressProcessor) dispatcher.addAudioProcessor(progressProcessor)
@@ -400,7 +436,11 @@ class VoiceAnonymizer {
} }
} }
} finally { } finally {
encoder.stop() try {
encoder.stop()
} catch (_: IllegalStateException) {
// Encoder was never started
}
encoder.release() encoder.release()
if (muxerStarted) { if (muxerStarted) {
muxer.stop() muxer.stop()
@@ -446,5 +486,7 @@ private class FloatArrayAudioInputStream(
return actualSkip.toLong() * 2 return actualSkip.toLong() * 2
} }
override fun close() {} override fun close() {
// No-op: no cleanup needed
}
} }
@@ -35,8 +35,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -67,6 +69,8 @@ fun VoiceMessagePreview(
voiceMetadata: AudioMeta, voiceMetadata: AudioMeta,
localFile: File? = null, localFile: File? = null,
onRemove: () -> Unit, onRemove: () -> Unit,
onReRecord: ((RecordingResult) -> Unit)? = null,
isUploading: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -101,77 +105,159 @@ fun VoiceMessagePreview(
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
).padding(12.dp), ).padding(12.dp),
) { ) {
Row( Column {
modifier = Modifier.fillMaxWidth(), Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically,
) { horizontalArrangement = Arrangement.SpaceBetween,
// Play/Pause Button
IconButton(
onClick = {
handlePlayPauseClick(
mediaPlayer = mediaPlayer,
isPlaying = isPlaying,
progress = progress,
onProgressReset = { progress = 0f },
onPlayingChanged = { isPlaying = it },
)
},
modifier = Modifier.size(48.dp),
) { ) {
Icon( // Play/Pause Button
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, IconButton(
contentDescription = if (isPlaying) stringRes(context, R.string.pause) else stringRes(context, R.string.play), onClick = {
tint = MaterialTheme.colorScheme.primary, handlePlayPauseClick(
)
}
Spacer(modifier = Modifier.width(8.dp))
// Waveform and Duration
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
) {
AudioWaveformReadOnly(
amplitudes = voiceMetadata.waveform ?: emptyList(),
progress = progress,
waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)),
progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)),
onProgressChange = { newProgress ->
handleWaveformScrub(
newProgress = newProgress,
mediaPlayer = mediaPlayer, mediaPlayer = mediaPlayer,
onProgressChanged = { progress = it }, isPlaying = isPlaying,
progress = progress,
onProgressReset = { progress = 0f },
onPlayingChanged = { isPlaying = it },
) )
}, },
) modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) stringRes(context, R.string.pause) else stringRes(context, R.string.play),
tint = MaterialTheme.colorScheme.primary,
)
}
Text( Spacer(modifier = Modifier.width(8.dp))
text = formatSecondsToTime(voiceMetadata.duration ?: 0),
style = MaterialTheme.typography.bodySmall, // Waveform and Duration
color = MaterialTheme.colorScheme.onSurfaceVariant, Column(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.weight(1f),
) verticalArrangement = Arrangement.Center,
) {
AudioWaveformReadOnly(
amplitudes = voiceMetadata.waveform ?: emptyList(),
progress = progress,
waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)),
progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)),
onProgressChange = { newProgress ->
handleWaveformScrub(
newProgress = newProgress,
mediaPlayer = mediaPlayer,
onProgressChanged = { progress = it },
)
},
)
Text(
text = formatSecondsToTime(voiceMetadata.duration ?: 0),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
Spacer(modifier = Modifier.width(8.dp))
// Remove Button
IconButton(
onClick = onRemove,
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringRes(context, R.string.remove),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
Spacer(modifier = Modifier.width(8.dp)) if (onReRecord != null) {
Spacer(modifier = Modifier.size(8.dp))
// Remove Button ReRecordButton(
IconButton( isUploading = isUploading,
onClick = onRemove, isPlaying = isPlaying,
modifier = Modifier.size(48.dp), onRecordTaken = onReRecord,
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringRes(context, R.string.remove),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
} }
} }
@Composable
private fun ReRecordButton(
isUploading: Boolean,
isPlaying: Boolean,
onRecordTaken: (RecordingResult) -> Unit,
) {
if (isUploading || isPlaying) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = stringRes(id = R.string.record_a_message),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringRes(id = R.string.re_record),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
RecordAudioBox(
modifier = Modifier,
onRecordTaken = onRecordTaken,
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
) { isRecording, elapsedSeconds ->
val contentColor =
if (isRecording) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val icon =
if (isRecording) {
Icons.Default.Stop
} else {
Icons.Default.Mic
}
val label =
if (isRecording) {
formatSecondsToTime(elapsedSeconds)
} else {
stringRes(id = R.string.re_record)
}
val iconDescription =
if (isRecording) {
stringRes(id = R.string.recording_indicator_description)
} else {
stringRes(id = R.string.record_a_message)
}
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = icon,
contentDescription = iconDescription,
tint = contentColor,
)
Text(
text = label,
color = contentColor,
)
}
}
}
@Composable @Composable
private fun ManageMediaPlayer( private fun ManageMediaPlayer(
voiceMetadata: AudioMeta, voiceMetadata: AudioMeta,
@@ -29,5 +29,5 @@ enum class VoicePreset(
NONE(1.0, R.string.voice_preset_none), NONE(1.0, R.string.voice_preset_none),
DEEP(1.4, R.string.voice_preset_deep), DEEP(1.4, R.string.voice_preset_deep),
HIGH(0.75, R.string.voice_preset_high), HIGH(0.75, R.string.voice_preset_high),
NEUTRAL(0.9, R.string.voice_preset_neutral), NEUTRAL(1.1, R.string.voice_preset_neutral),
} }
@@ -113,6 +113,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
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.FloatingRecordingIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox 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
@@ -635,6 +636,7 @@ fun ReplyViaVoiceReaction(
) )
} }
}, },
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
) { isRecording, elapsedSeconds -> ) { isRecording, elapsedSeconds ->
if (voiceRecordingState != null) { if (voiceRecordingState != null) {
SideEffect { SideEffect {
@@ -20,8 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.note.types package com.vitorpamplona.amethyst.ui.note.types
import android.R.attr.label
import android.R.attr.maxLines
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -374,6 +375,8 @@ private fun NewPostScreenBody(
VoiceMessagePreview( VoiceMessagePreview(
voiceMetadata = displayMetadata, voiceMetadata = displayMetadata,
localFile = postViewModel.activeFile, localFile = postViewModel.activeFile,
onReRecord = { recording -> postViewModel.selectVoiceRecording(recording) },
isUploading = postViewModel.isUploadingVoice,
onRemove = { postViewModel.removeVoiceMessage() }, onRemove = { postViewModel.removeVoiceMessage() },
) )
@@ -497,6 +500,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
onVoiceTaken = { recording -> onVoiceTaken = { recording ->
postViewModel.selectVoiceRecording(recording) postViewModel.selectVoiceRecording(recording)
}, },
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
) )
if (postViewModel.canUsePoll) { if (postViewModel.canUsePoll) {
@@ -21,46 +21,32 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.actions.uploads.formatSecondsToTime
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.replyModifier
@@ -103,9 +89,6 @@ fun VoiceReplyScreen(
}, },
) )
}, },
bottomBar = {
ReRecordButton(viewModel)
},
) { pad -> ) { pad ->
Surface( Surface(
modifier = modifier =
@@ -161,6 +144,8 @@ private fun VoiceReplyScreenBody(
VoiceMessagePreview( VoiceMessagePreview(
voiceMetadata = displayMetadata, voiceMetadata = displayMetadata,
localFile = viewModel.activeFile, localFile = viewModel.activeFile,
onReRecord = { recording -> viewModel.selectRecording(recording) },
isUploading = viewModel.isUploading,
onRemove = { onRemove = {
viewModel.cancel() viewModel.cancel()
nav.popBack() nav.popBack()
@@ -192,80 +177,3 @@ private fun VoiceReplyScreenBody(
Spacer(modifier = Modifier.height(80.dp)) Spacer(modifier = Modifier.height(80.dp))
} }
} }
@Composable
private fun ReRecordButton(viewModel: VoiceReplyViewModel) {
Column(
modifier =
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(vertical = 16.dp, horizontal = Size10dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (viewModel.isUploading) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = stringRes(id = R.string.record_a_message),
tint = MaterialTheme.colorScheme.onBackground,
)
Text(
text = stringRes(id = R.string.re_record),
color = MaterialTheme.colorScheme.onBackground,
)
}
return
}
RecordAudioBox(
modifier = Modifier,
onRecordTaken = { recording ->
viewModel.selectRecording(recording)
},
) { isRecording, elapsedSeconds ->
val contentColor =
if (isRecording) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onBackground
}
val icon =
if (isRecording) {
Icons.Default.Stop
} else {
Icons.Default.Mic
}
val label =
if (isRecording) {
formatSecondsToTime(elapsedSeconds)
} else {
stringRes(id = R.string.re_record)
}
val iconDescription =
if (isRecording) {
stringRes(id = R.string.recording_indicator_description)
} else {
stringRes(id = R.string.record_a_message)
}
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = icon,
contentDescription = iconDescription,
tint = contentColor,
)
Text(
text = label,
color = contentColor,
)
}
}
}
}
@@ -135,6 +135,8 @@
<string name="add_a_user">Ajouter un utilisateur</string> <string name="add_a_user">Ajouter un utilisateur</string>
<string name="add_a_relay">Ajouter un relais</string> <string name="add_a_relay">Ajouter un relais</string>
<string name="profile_name">Nom</string> <string name="profile_name">Nom</string>
<string name="profile_name_with_explainer">Nom (pour @tagging)</string>
<string name="my_name">Mon nom @tag</string>
<string name="display_name">Nom visible du public</string> <string name="display_name">Nom visible du public</string>
<string name="my_display_name">Mon nom visible du public</string> <string name="my_display_name">Mon nom visible du public</string>
<string name="my_awesome_name">Ostrich McGénial</string> <string name="my_awesome_name">Ostrich McGénial</string>
@@ -170,6 +172,12 @@
<string name="upload_error_voice_message_unexpected_state">Erreur lors de l\'envoi</string> <string name="upload_error_voice_message_unexpected_state">Erreur lors de l\'envoi</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 n\'est pas encore supporté pour les messages vocaux</string> <string name="upload_error_voice_message_nip95_not_supported">NIP-95 n\'est pas encore supporté pour les messages vocaux</string>
<string name="upload_error_voice_message_exception">Échec de l\'envoi de la voix : %1$s</string> <string name="upload_error_voice_message_exception">Échec de l\'envoi de la voix : %1$s</string>
<string name="voice_preset_none">Aucun</string>
<string name="voice_preset_deep">Profond</string>
<string name="voice_preset_high">Haute</string>
<string name="voice_preset_neutral">Neutre</string>
<string name="voice_anonymize_title">Anonymisation</string>
<string name="voice_anonymize_description">Modifie votre niveau de voix. Remarque : les changements de hauteur de base peuvent potentiellement être inversés par des auditeurs déterminés.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats</string>
<string name="reply_here">"répondre ici"</string> <string name="reply_here">"répondre ici"</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copie l\'ID de note dans le presse-papiers pour le partage sur Nostr</string> <string name="copies_the_note_id_to_the_clipboard_for_sharing">Copie l\'ID de note dans le presse-papiers pour le partage sur Nostr</string>
@@ -364,6 +372,7 @@
<string name="manual_zaps">Division manuelle de zaps</string> <string name="manual_zaps">Division manuelle de zaps</string>
<string name="bookmarks">Favoris</string> <string name="bookmarks">Favoris</string>
<string name="bookmarks_title">Signets par défaut</string> <string name="bookmarks_title">Signets par défaut</string>
<string name="bookmarks_explainer">Vos favoris par défaut que de nombreux clients prennent en charge</string>
<string name="drafts">Brouillons</string> <string name="drafts">Brouillons</string>
<string name="private_bookmarks">Favoris Privés</string> <string name="private_bookmarks">Favoris Privés</string>
<string name="public_bookmarks">Favoris Publics</string> <string name="public_bookmarks">Favoris Publics</string>
@@ -371,6 +380,40 @@
<string name="add_to_public_bookmarks">Ajouter aux Favoris Publics</string> <string name="add_to_public_bookmarks">Ajouter aux Favoris Publics</string>
<string name="remove_from_private_bookmarks">Supprimer des Favoris Privés</string> <string name="remove_from_private_bookmarks">Supprimer des Favoris Privés</string>
<string name="remove_from_public_bookmarks">Supprimer des Favoris Publics</string> <string name="remove_from_public_bookmarks">Supprimer des Favoris Publics</string>
<string name="bookmark_lists">Listes de favoris</string>
<string name="bookmark_list_icon_label">Icône pour la liste de favoris</string>
<string name="bookmark_list_creation_screen_title">Nouvelle liste de favoris</string>
<string name="bookmark_list_edit_sub_title">Métadonnées de la liste de favoris</string>
<string name="bookmark_list_clone_btn_label">Dupliquer la liste de favoris</string>
<string name="bookmark_list_broadcast_btn_label">Diffuser la liste de favoris</string>
<string name="bookmark_list_delete_btn_label">Supprimer la liste de favoris</string>
<string name="bookmark_list_posts_btn_label">Voir les publications</string>
<string name="bookmark_list_articles_btn_label">Voir les articles</string>
<string name="bookmark_list_links_btn_label">Voir les liens</string>
<string name="bookmark_list_hashtags_btn_label">Voir les hashtags</string>
<string name="bookmark_list_feed_empty_msg">Vous n\'avez pas encore de liste de favoris. Appuyez sur le bouton ci-dessous pour en créer une.</string>
<string name="private_posts_label">Publications privées</string>
<string name="private_posts_count">Publications privées (%1$s)</string>
<string name="public_posts_label">Publications publiques</string>
<string name="public_posts_count">Publications publiques (%1$s)</string>
<string name="private_articles_label">Articles privés</string>
<string name="private_articles_count">Articles privés (%1$s)</string>
<string name="public_articles_label">Articles publics</string>
<string name="public_articles_count">Articles publics (%1$s)</string>
<string name="manage_bookmark_label">Gérer %1$s dans les listes de favoris</string>
<string name="post_bookmark_management_title">Ajouter cette publication aux favoris</string>
<string name="article_bookmark_management_title">Ajouter cet article aux favoris</string>
<string name="public_bookmark_presence_indicator">est un favori public ici</string>
<string name="private_bookmark_presence_indicator">est un favori privé ici</string>
<string name="bookmark_absence_indicator">n\'est pas un favori ici</string>
<string name="bookmark_remove_action_desc">Supprimer le favori de la liste</string>
<string name="bookmark_add_action_desc">Ajouter un favori à la liste</string>
<string name="public_bookmark_add_action_label">Ajouter comme favori public</string>
<string name="private_bookmark_add_action_label">Ajouter comme favori privé</string>
<string name="bookmark_remove_action_label">Supprimer de la liste de favoris</string>
<string name="bookmark_list_explainer">Les métadonnées des listes de favoris peuvent être vues par n\'importe qui sur Nostr. Seuls vos membres privés sont chiffrés.</string>
<string name="move_bookmark_to_public_label">Déplacer en public</string>
<string name="move_bookmark_to_private_label">Déplacer en privé</string>
<string name="wallet_connect_service">Service Wallet Connect</string> <string name="wallet_connect_service">Service Wallet Connect</string>
<string name="wallet_connect_service_explainer">Autorise un Nostr Secret à payer des zaps sans quitter l\'application. Gardez le secret en toute sécurité et utilisez un relais privé si possible</string> <string name="wallet_connect_service_explainer">Autorise un Nostr Secret à payer des zaps sans quitter l\'application. Gardez le secret en toute sécurité et utilisez un relais privé si possible</string>
<string name="wallet_connect_service_pubkey">Clé publique Wallet Connect</string> <string name="wallet_connect_service_pubkey">Clé publique Wallet Connect</string>
@@ -604,12 +647,23 @@
<string name="contact">Contact</string> <string name="contact">Contact</string>
<string name="supports">NIPs supportés</string> <string name="supports">NIPs supportés</string>
<string name="admission_fees">Frais d\'admission</string> <string name="admission_fees">Frais d\'admission</string>
<string name="publication">Publication</string>
<string name="payment_link">Paiements %1$s</string>
<string name="payments_url">URL de payments</string> <string name="payments_url">URL de payments</string>
<string name="target_audience">Audience cible</string>
<string name="policies_and_links">Politiques &amp; liens</string>
<string name="fees_and_payments">Frais &amp; paiements</string>
<string name="limitations">Limites</string> <string name="limitations">Limites</string>
<string name="countries">Pays</string> <string name="countries">Pays</string>
<string name="languages">Langages</string> <string name="languages">Langages</string>
<string name="tags">Étiquettes</string> <string name="tags">Étiquettes</string>
<string name="topics">Sujets</string>
<string name="all_countries">Tous les pays</string>
<string name="all_languages">Toutes les langues</string>
<string name="posting_policy">Politique de publication</string> <string name="posting_policy">Politique de publication</string>
<string name="privacy_policy">Politique de confidentialité</string>
<string name="terms_and_conditions">Termes &amp; Conditions</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Erreurs et Notifications de ce Relais</string> <string name="relay_error_messages">Erreurs et Notifications de ce Relais</string>
<string name="message_length">Longueur du message</string> <string name="message_length">Longueur du message</string>
<string name="subscriptions">Abonnements</string> <string name="subscriptions">Abonnements</string>
@@ -618,9 +672,19 @@
<string name="minimum_prefix">Préfixe minimum</string> <string name="minimum_prefix">Préfixe minimum</string>
<string name="maximum_event_tags">Tags d\'événement maximum</string> <string name="maximum_event_tags">Tags d\'événement maximum</string>
<string name="content_length">Longueur du contenu</string> <string name="content_length">Longueur du contenu</string>
<string name="max_content_length">Longueur maximale du contenu</string>
<string name="discards_older_than">Rejeter plus anciens que</string>
<string name="accepts_up_to">Accepte jusqu\'à</string>
<string name="time_in_the_future">%1$s dans le futur</string>
<string name="time_in_the_past">Il y a %1$s</string>
<string name="content_size">Taille du contenu</string>
<string name="connectivity">Connectivité</string>
<string name="access_control">Contrôle d\'accès</string>
<string name="minimum_pow">PoW minimum</string> <string name="minimum_pow">PoW minimum</string>
<string name="auth">Authentification</string> <string name="auth">Authentification</string>
<string name="auth_required">Authentification requise</string>
<string name="payment">Paiement</string> <string name="payment">Paiement</string>
<string name="payment_required">Paiement requis</string>
<string name="cashu">Jeton Cashu</string> <string name="cashu">Jeton Cashu</string>
<string name="cashu_redeem">Échanger</string> <string name="cashu_redeem">Échanger</string>
<string name="cashu_redeem_to_zap">Envoyer vers le portefeuille Zap</string> <string name="cashu_redeem_to_zap">Envoyer vers le portefeuille Zap</string>
@@ -172,6 +172,12 @@
<string name="upload_error_voice_message_unexpected_state">अनपेक्षित आरोहण स्थिति</string> <string name="upload_error_voice_message_unexpected_state">अनपेक्षित आरोहण स्थिति</string>
<string name="upload_error_voice_message_nip95_not_supported">निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए</string> <string name="upload_error_voice_message_nip95_not_supported">निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए</string>
<string name="upload_error_voice_message_exception">ध्वनि आरोहण असफल : %1$s</string> <string name="upload_error_voice_message_exception">ध्वनि आरोहण असफल : %1$s</string>
<string name="voice_preset_none">कुछ भी नहीं</string>
<string name="voice_preset_deep">गहन</string>
<string name="voice_preset_high">उच्च</string>
<string name="voice_preset_neutral">मध्यम</string>
<string name="voice_anonymize_title">अनामीकरण</string>
<string name="voice_anonymize_description">आपके स्वर का परिवर्तन करता है। ध्यातव्य। आधारभूत स्वर परिवर्तन को पूर्ववत किया जा सकता है दृढ निश्चित श्रोताओं द्वारा।</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके</string>
<string name="reply_here">"उत्तर यहाँ दें.. "</string> <string name="reply_here">"उत्तर यहाँ दें.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">नोस्ट्र में बाँटने के लिए टीका विभेदक की अनुकृति करता है टाँकाफलक में</string> <string name="copies_the_note_id_to_the_clipboard_for_sharing">नोस्ट्र में बाँटने के लिए टीका विभेदक की अनुकृति करता है टाँकाफलक में</string>
@@ -1232,4 +1238,5 @@
<string name="follow_pack_broadcast">पोटली प्रसारण</string> <string name="follow_pack_broadcast">पोटली प्रसारण</string>
<string name="follow_set_delete">सूची मिटाएँ</string> <string name="follow_set_delete">सूची मिटाएँ</string>
<string name="follow_pack_delete">पोटली मिटाएँ</string> <string name="follow_pack_delete">पोटली मिटाएँ</string>
<string name="kinds">प्रकार</string>
</resources> </resources>
@@ -172,6 +172,12 @@
<string name="upload_error_voice_message_unexpected_state">Váratlan feltöltési állapot</string> <string name="upload_error_voice_message_unexpected_state">Váratlan feltöltési állapot</string>
<string name="upload_error_voice_message_nip95_not_supported">A NIP-95 még nem támogatja a hangüzeneteket</string> <string name="upload_error_voice_message_nip95_not_supported">A NIP-95 még nem támogatja a hangüzeneteket</string>
<string name="upload_error_voice_message_exception">Nem sikerült feltölteni a hangüzenetet: %1$s</string> <string name="upload_error_voice_message_exception">Nem sikerült feltölteni a hangüzenetet: %1$s</string>
<string name="voice_preset_none">Semmi</string>
<string name="voice_preset_deep">Mély</string>
<string name="voice_preset_high">Magas</string>
<string name="voice_preset_neutral">Semleges</string>
<string name="voice_anonymize_title">Névtelenítés</string>
<string name="voice_anonymize_description">Módosítja az Ön hangmagasságát. Megjegyzés: az alapvető hangmagasság-változásokat a figyelmes hallgatók visszafordíthatják.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">A felhasználó nem rendelkezik a satoshik fogadásához beállított lightning-címmel</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">A felhasználó nem rendelkezik a satoshik fogadásához beállított lightning-címmel</string>
<string name="reply_here">"Válasz írása… "</string> <string name="reply_here">"Válasz írása… "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">A bejegyzés azonosítóját a vágólapra másolja a Nostr-ban való megosztáshoz</string> <string name="copies_the_note_id_to_the_clipboard_for_sharing">A bejegyzés azonosítóját a vágólapra másolja a Nostr-ban való megosztáshoz</string>
@@ -172,6 +172,12 @@
<string name="upload_error_voice_message_unexpected_state">未知的上传状态</string> <string name="upload_error_voice_message_unexpected_state">未知的上传状态</string>
<string name="upload_error_voice_message_nip95_not_supported">语音消息尚不支持 NIP-95</string> <string name="upload_error_voice_message_nip95_not_supported">语音消息尚不支持 NIP-95</string>
<string name="upload_error_voice_message_exception">语音上传失败:%1$s</string> <string name="upload_error_voice_message_exception">语音上传失败:%1$s</string>
<string name="voice_preset_none"></string>
<string name="voice_preset_deep">深沉</string>
<string name="voice_preset_high">高音</string>
<string name="voice_preset_neutral">中性</string>
<string name="voice_anonymize_title">匿名化</string>
<string name="voice_anonymize_description">更改您的音高。注意:听众如果下定决定也许能逆转基础音高更改。</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">用户尚未设置闪电地址以接收聪</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">用户尚未设置闪电地址以接收聪</string>
<string name="reply_here">"🔏在此回复… "</string> <string name="reply_here">"🔏在此回复… "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">复制笔记ID到剪贴板,供分享</string> <string name="copies_the_note_id_to_the_clipboard_for_sharing">复制笔记ID到剪贴板,供分享</string>
@@ -1232,4 +1238,5 @@
<string name="follow_pack_broadcast">广播包</string> <string name="follow_pack_broadcast">广播包</string>
<string name="follow_set_delete">删除列表</string> <string name="follow_set_delete">删除列表</string>
<string name="follow_pack_delete">删除包</string> <string name="follow_pack_delete">删除包</string>
<string name="kinds">类型</string>
</resources> </resources>
@@ -34,7 +34,6 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import kotlin.collections.plus
@Stable @Stable
class Chatroom : NotesGatherer { class Chatroom : NotesGatherer {
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.commons.richtext package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import java.util.Base64 import java.util.Base64
object Base64Image { object Base64Image {
@@ -45,7 +45,6 @@ import java.net.MalformedURLException
import java.net.URISyntaxException import java.net.URISyntaxException
import java.net.URL import java.net.URL
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.text.iterator
class RichTextParser { class RichTextParser {
fun createMediaContent( fun createMediaContent(
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.desktop.network package com.vitorpamplona.amethyst.desktop.network
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
/** /**
@@ -49,7 +49,6 @@ import com.vitorpamplona.quartz.nip19Bech32.pubKeys
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.JsonNull.content
@Immutable @Immutable
class TextNoteEvent( class TextNoteEvent(