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.isActive
const val MAX_VOICE_RECORD_SECONDS = 600
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun RecordAudioBox(
modifier: Modifier,
onRecordTaken: (RecordingResult) -> Unit,
maxDurationSeconds: Int? = null,
content: @Composable (Boolean, Int) -> Unit,
) {
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
@@ -107,6 +110,10 @@ fun RecordAudioBox(
while (isActive) {
delay(1000)
elapsedSeconds++
if (maxDurationSeconds != null && elapsedSeconds >= maxDurationSeconds) {
stopRecording()
break
}
}
} else {
// Reset elapsed time when not recording
@@ -42,7 +42,10 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) {
fun RecordVoiceButton(
onVoiceTaken: (RecordingResult) -> Unit,
maxDurationSeconds: Int? = null,
) {
var isRecording by remember { mutableStateOf(false) }
var elapsedSeconds by remember { mutableIntStateOf(0) }
@@ -61,6 +64,7 @@ fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) {
elapsedSeconds = 0
onVoiceTaken(recording)
},
maxDurationSeconds = maxDurationSeconds,
) { recordingState, elapsed ->
// Update parent state after composition completes
SideEffect {
@@ -61,6 +61,7 @@ class VoiceAnonymizationController(
preset: VoicePreset,
originalFile: File?,
) {
Log.d(logTag, "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}")
if (processingPreset != null || preset == selectedPreset) return
if (preset == VoicePreset.NONE) {
@@ -76,9 +77,9 @@ class VoiceAnonymizationController(
val file = originalFile ?: return
processingJob?.cancel()
processingPreset = preset
processingJob =
scope.launch {
processingPreset = preset
try {
val anonymizer = VoiceAnonymizer()
val result = anonymizer.anonymize(file, preset)
@@ -108,8 +109,11 @@ class VoiceAnonymizationController(
distortedFiles.values.forEach { result ->
try {
if (result.file.exists()) {
result.file.delete()
Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}")
if (result.file.delete()) {
Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}")
} else {
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}")
}
}
} catch (e: Exception) {
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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
@@ -63,7 +62,7 @@ fun VoiceAnonymizationSection(
Text(
text = stringRes(R.string.voice_anonymize_description),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
@@ -41,12 +41,26 @@ import java.io.File
import java.nio.ByteOrder
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(
val file: File,
val waveform: List<Float>,
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 {
companion object {
private const val TAG = "VoiceAnonymizer"
@@ -54,6 +68,20 @@ class VoiceAnonymizer {
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(
inputFile: File,
preset: VoicePreset,
@@ -98,7 +126,8 @@ class VoiceAnonymizer {
): File {
val baseName = inputFile.nameWithoutExtension
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(
@@ -129,9 +158,7 @@ class VoiceAnonymizer {
}
}
if (audioTrackIndex == -1 || format == null) {
throw IllegalStateException("No audio track found in file")
}
check(audioTrackIndex != -1 && format != null) { "No audio track found in file" }
extractor.selectTrack(audioTrackIndex)
val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm"
@@ -143,7 +170,8 @@ class VoiceAnonymizer {
decoder.configure(format, null, null, 0)
decoder.start()
val pcmSamples = mutableListOf<Float>()
val estimatedSamples = (sampleRate.toLong() * durationUs / 1_000_000).toInt()
val pcmSamples = ArrayList<Float>(estimatedSamples)
val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false
var outputDone = false
@@ -196,7 +224,11 @@ class VoiceAnonymizer {
return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration)
} finally {
decoder?.stop()
try {
decoder?.stop()
} catch (_: IllegalStateException) {
// Decoder was never started
}
decoder?.release()
extractor.release()
}
@@ -218,8 +250,8 @@ class VoiceAnonymizer {
}
else -> baseFactor
}
val processedSamples = mutableListOf<Float>()
val totalSamples = pcmData.size
val processedSamples = ArrayList<Float>(totalSamples)
val wsola =
WaveformSimilarityBasedOverlapAdd(
@@ -252,7 +284,9 @@ class VoiceAnonymizer {
return true
}
override fun processingFinished() {}
override fun processingFinished() {
// No-op: no cleanup needed
}
}
val dispatcher =
@@ -276,7 +310,9 @@ class VoiceAnonymizer {
return true
}
override fun processingFinished() {}
override fun processingFinished() {
// No-op: no cleanup needed
}
}
dispatcher.addAudioProcessor(progressProcessor)
@@ -400,7 +436,11 @@ class VoiceAnonymizer {
}
}
} finally {
encoder.stop()
try {
encoder.stop()
} catch (_: IllegalStateException) {
// Encoder was never started
}
encoder.release()
if (muxerStarted) {
muxer.stop()
@@ -446,5 +486,7 @@ private class FloatArrayAudioInputStream(
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.material.icons.Icons
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.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -67,6 +69,8 @@ fun VoiceMessagePreview(
voiceMetadata: AudioMeta,
localFile: File? = null,
onRemove: () -> Unit,
onReRecord: ((RecordingResult) -> Unit)? = null,
isUploading: Boolean = false,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@@ -101,77 +105,159 @@ fun VoiceMessagePreview(
shape = RoundedCornerShape(8.dp),
).padding(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
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),
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
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,
)
}
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,
// Play/Pause Button
IconButton(
onClick = {
handlePlayPauseClick(
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(
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))
// 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,
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))
// 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,
if (onReRecord != null) {
Spacer(modifier = Modifier.size(8.dp))
ReRecordButton(
isUploading = isUploading,
isPlaying = isPlaying,
onRecordTaken = onReRecord,
)
}
}
}
}
@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
private fun ManageMediaPlayer(
voiceMetadata: AudioMeta,
@@ -29,5 +29,5 @@ enum class VoicePreset(
NONE(1.0, R.string.voice_preset_none),
DEEP(1.4, R.string.voice_preset_deep),
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.ui.actions.CrossfadeIfEnabled
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.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.ClickableBox
@@ -635,6 +636,7 @@ fun ReplyViaVoiceReaction(
)
}
},
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
) { isRecording, elapsedSeconds ->
if (voiceRecordingState != null) {
SideEffect {
@@ -20,8 +20,6 @@
*/
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.layout.Column
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.ui.actions.mediaServers.FileServerSelectionRow
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.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -374,6 +375,8 @@ private fun NewPostScreenBody(
VoiceMessagePreview(
voiceMetadata = displayMetadata,
localFile = postViewModel.activeFile,
onReRecord = { recording -> postViewModel.selectVoiceRecording(recording) },
isUploading = postViewModel.isUploadingVoice,
onRemove = { postViewModel.removeVoiceMessage() },
)
@@ -497,6 +500,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
onVoiceTaken = { recording ->
postViewModel.selectVoiceRecording(recording)
},
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
)
if (postViewModel.canUsePoll) {
@@ -21,46 +21,32 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
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.Icon
import androidx.compose.material3.MaterialTheme
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.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
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.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
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.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
@@ -103,9 +89,6 @@ fun VoiceReplyScreen(
},
)
},
bottomBar = {
ReRecordButton(viewModel)
},
) { pad ->
Surface(
modifier =
@@ -161,6 +144,8 @@ private fun VoiceReplyScreenBody(
VoiceMessagePreview(
voiceMetadata = displayMetadata,
localFile = viewModel.activeFile,
onReRecord = { recording -> viewModel.selectRecording(recording) },
isUploading = viewModel.isUploading,
onRemove = {
viewModel.cancel()
nav.popBack()
@@ -192,80 +177,3 @@ private fun VoiceReplyScreenBody(
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_relay">Ajouter un relais</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="my_display_name">Mon nom visible du public</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_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="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="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>
@@ -364,6 +372,7 @@
<string name="manual_zaps">Division manuelle de zaps</string>
<string name="bookmarks">Favoris</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="private_bookmarks">Favoris Privés</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="remove_from_private_bookmarks">Supprimer des Favoris Privés</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_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>
@@ -604,12 +647,23 @@
<string name="contact">Contact</string>
<string name="supports">NIPs supportés</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="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="countries">Pays</string>
<string name="languages">Langages</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="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="message_length">Longueur du message</string>
<string name="subscriptions">Abonnements</string>
@@ -618,9 +672,19 @@
<string name="minimum_prefix">Préfixe minimum</string>
<string name="maximum_event_tags">Tags d\'événement maximum</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="auth">Authentification</string>
<string name="auth_required">Authentification requise</string>
<string name="payment">Paiement</string>
<string name="payment_required">Paiement requis</string>
<string name="cashu">Jeton Cashu</string>
<string name="cashu_redeem">Échanger</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_nip95_not_supported">निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए</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="reply_here">"उत्तर यहाँ दें.. "</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_set_delete">सूची मिटाएँ</string>
<string name="follow_pack_delete">पोटली मिटाएँ</string>
<string name="kinds">प्रकार</string>
</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_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="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="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>
@@ -172,6 +172,12 @@
<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_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="reply_here">"🔏在此回复… "</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_set_delete">删除列表</string>
<string name="follow_pack_delete">删除包</string>
<string name="kinds">类型</string>
</resources>
@@ -34,7 +34,6 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference
import kotlin.collections.plus
@Stable
class Chatroom : NotesGatherer {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import java.util.Base64
object Base64Image {
@@ -45,7 +45,6 @@ import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import kotlin.coroutines.cancellation.CancellationException
import kotlin.text.iterator
class RichTextParser {
fun createMediaContent(
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.desktop.network
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
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.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.JsonNull.content
@Immutable
class TextNoteEvent(