Merge remote-tracking branch 'origin/main' into claude/fix-video-mime-type-sqkiz

This commit is contained in:
Claude
2026-05-18 22:04:15 +00:00
42 changed files with 1793 additions and 308 deletions
@@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList
@@ -207,24 +207,34 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
)
}
fun hasPushNotificationProvider(): Boolean = true
@Composable
fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {
fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {
val context = LocalContext.current
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
SettingsRow(
R.string.push_server_title,
R.string.push_server_explainer,
selectedItems = readableListWithExplainer,
selectedIndex = list.indexOf(currentDistributor),
) { index ->
if (list[index] == "None") {
sharedPrefs.dontAskForNotificationPermissions()
sharedPrefs.dontShowPushNotificationSelector()
PushDistributorHandler.forceRemoveDistributor(context)
} else {
PushDistributorHandler.saveDistributor(list[index])
}
val selectedIndex = list.indexOf(currentDistributor).coerceAtLeast(0)
SettingsBlockTile(
icon = MaterialSymbols.CloudSync,
title = stringRes(R.string.push_server_title),
description = stringRes(R.string.push_server_explainer),
) {
TextSpinner(
label = null,
placeholder = readableListWithExplainer[selectedIndex].title,
options = readableListWithExplainer,
onSelect = { index ->
if (list[index] == "None") {
sharedPrefs.dontAskForNotificationPermissions()
sharedPrefs.dontShowPushNotificationSelector()
PushDistributorHandler.forceRemoveDistributor(context)
} else {
PushDistributorHandler.saveDistributor(list[index])
}
},
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@@ -3067,8 +3067,10 @@ class Account(
fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors
private fun hasExcessiveHashtags(note: Note): Boolean {
val limit = settings.syncedSettings.security.maxHashtagLimit.value
fun maxHashtagLimit(): Int = settings.syncedSettings.security.maxHashtagLimit.value
fun hasExcessiveHashtags(note: Note): Boolean {
val limit = maxHashtagLimit()
return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true
}
@@ -60,7 +60,7 @@ import kotlinx.coroutines.withContext
*/
object CallNotifier {
private var callChannel: NotificationChannel? = null
private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
private const val CALL_NOTIFICATION_ID = 0x50000
fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
@@ -0,0 +1,166 @@
/*
* 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.service.notifications
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
/**
* Registry of user-facing notification channels and helpers to read their
* current importance / open the system settings page for them.
*
* Android (post-Oreo) owns channel state — the app cannot toggle channel
* importance directly. The Notifications settings screen surfaces the
* channels here and routes the user to the system per-channel page.
*
* Foreground-service channels (relay-connection, nests audio) are
* intentionally omitted: they're functional indicators, not content
* notifications, and disabling them breaks the foreground service contract.
*/
object NotificationChannels {
private const val TAG = "NotificationChannels"
enum class ChannelStatus { ON, SILENT, OFF }
/**
* A single content-bearing notification channel exposed in the settings UI.
* [ensure] creates the channel if missing — needed so the system per-channel
* settings page has something to open even before the first notification fires.
*/
data class Entry(
val nameRes: Int,
val icon: MaterialSymbol,
val channelId: (Context) -> String,
val ensure: (Context) -> Unit,
)
val contentChannels: List<Entry> =
listOf(
Entry(
nameRes = R.string.app_notification_dms_channel_name,
icon = MaterialSymbols.Mail,
channelId = { stringRes(it, R.string.app_notification_dms_channel_id) },
ensure = { NotificationUtils.getOrCreateDMChannel(it) },
),
Entry(
nameRes = R.string.app_notification_mentions_channel_name,
icon = MaterialSymbols.AlternateEmail,
channelId = { stringRes(it, R.string.app_notification_mentions_channel_id) },
ensure = { NotificationUtils.getOrCreateMentionChannel(it) },
),
Entry(
nameRes = R.string.app_notification_replies_channel_name,
icon = MaterialSymbols.Chat,
channelId = { stringRes(it, R.string.app_notification_replies_channel_id) },
ensure = { NotificationUtils.getOrCreateReplyChannel(it) },
),
Entry(
nameRes = R.string.app_notification_reactions_channel_name,
icon = MaterialSymbols.Favorite,
channelId = { stringRes(it, R.string.app_notification_reactions_channel_id) },
ensure = { NotificationUtils.getOrCreateReactionChannel(it) },
),
Entry(
nameRes = R.string.app_notification_zaps_channel_name,
icon = MaterialSymbols.Bolt,
channelId = { stringRes(it, R.string.app_notification_zaps_channel_id) },
ensure = { NotificationUtils.getOrCreateZapChannel(it) },
),
Entry(
nameRes = R.string.app_notification_chess_channel_name,
icon = MaterialSymbols.ChessKnight,
channelId = { stringRes(it, R.string.app_notification_chess_channel_id) },
ensure = { NotificationUtils.getOrCreateChessChannel(it) },
),
Entry(
nameRes = R.string.app_notification_scheduled_posts_channel_name,
icon = MaterialSymbols.Schedule,
channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) },
ensure = { ScheduledPostNotifier.ensureChannel(it) },
),
Entry(
nameRes = R.string.app_notification_calls_channel_name,
icon = MaterialSymbols.Call,
channelId = { CallNotifier.CALL_CHANNEL_ID },
ensure = { CallNotifier.getOrCreateCallChannel(it) },
),
)
fun statusOf(
context: Context,
channelId: String,
): ChannelStatus {
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return ChannelStatus.OFF
val nm = context.getSystemService(NotificationManager::class.java) ?: return ChannelStatus.OFF
val channel = nm.getNotificationChannel(channelId) ?: return ChannelStatus.ON
return when (channel.importance) {
NotificationManager.IMPORTANCE_NONE -> ChannelStatus.OFF
NotificationManager.IMPORTANCE_MIN, NotificationManager.IMPORTANCE_LOW -> ChannelStatus.SILENT
else -> ChannelStatus.ON
}
}
/**
* Opens the system per-channel notification settings page. Falls back to
* the app-level notification settings if the per-channel intent isn't
* supported (e.g. the channel was never created, or on stripped-down ROMs).
*/
fun openChannelSettings(
context: Context,
channelId: String,
) {
try {
val intent =
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, channelId)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
Log.w(TAG, "Per-channel intent failed, falling back to app notification settings", e)
openAppNotificationSettings(context)
}
}
fun openAppNotificationSettings(context: Context) {
try {
val intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to open app notification settings", e)
}
}
}
@@ -77,7 +77,7 @@ class SurgeDnsStore(
readRecords(file)
} catch (t: Throwable) {
Log.w(TAG) { "Dropping corrupt DNS cache blob: ${t.message}" }
file.delete()
if (!file.delete()) Log.w(TAG) { "Failed to delete corrupt DNS cache blob at ${file.path}" }
return
}
// restore() uses putIfAbsent and never marks dirty, so we deliberately do NOT clear the
@@ -104,8 +104,8 @@ class SurgeDnsStore(
file.parentFile?.mkdirs()
writeRecords(tmp, records)
if (!tmp.renameTo(file)) {
file.delete()
if (!tmp.renameTo(file)) {
// If delete fails the second rename will fail too; skip straight to the copy fallback.
if (!file.delete() || !tmp.renameTo(file)) {
tmp.copyTo(file, overwrite = true)
}
}
@@ -117,13 +117,13 @@ class SurgeDnsStore(
} finally {
// Cleans up after both happy paths (copyTo fallback) and failure paths (writeRecords
// crashed partway, leaving a partial blob) so a corrupt tmp can't accumulate.
if (tmp.exists()) tmp.delete()
if (tmp.exists() && !tmp.delete()) Log.w(TAG) { "Failed to delete DNS cache tmp file at ${tmp.path}" }
}
}
/** Force-clear the on-disk cache. Useful for diagnostics or when the user wipes data. */
fun clear() {
file.delete()
if (file.exists() && !file.delete()) Log.w(TAG) { "Failed to clear DNS cache blob at ${file.path}" }
}
private fun writeRecords(
@@ -122,7 +122,7 @@ object ScheduledPostNotifier {
}
}
private fun ensureChannel(context: Context) {
fun ensureChannel(context: Context) {
if (channel != null) return
channel =
NotificationChannel(
@@ -27,7 +27,6 @@ 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.R
import com.vitorpamplona.amethyst.commons.compose.currentWord
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
@@ -64,8 +63,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Stable
open class EditPostViewModel : ViewModel() {
@@ -186,81 +183,83 @@ open class EditPostViewModel : ViewModel() {
context: Context,
stripMetadata: Boolean = true,
) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.launchSigner {
val myAccount = account
val myMultiOrchestrator = multiOrchestrator ?: return@launch
val myMultiOrchestrator = multiOrchestrator ?: return@launchSigner
mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
val results =
myMultiOrchestrator.upload(
alt,
if (sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(mediaQuality),
server,
myAccount,
context,
useH265Codec,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
try {
val results =
myMultiOrchestrator.upload(
alt,
if (sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(mediaQuality),
server,
myAccount,
context,
useH265Codec,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
if (results.allGood) {
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 =
myAccount.createNip95(
byteArray = state.result.bytes,
headerInfo = state.result.fileHeader,
alt = alt,
contentWarningReason = if (sensitiveContent) "" else null,
)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
if (results.allGood) {
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 =
myAccount.createNip95(
byteArray = state.result.bytes,
headerInfo = state.result.fileHeader,
alt = alt,
contentWarningReason = if (sensitiveContent) "" else null,
)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
"nostr:" + it.toNEvent()
note?.let {
"nostr:" + it.toNEvent()
}
} 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.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
if (sensitiveContent) sensitiveContent("")
}.build()
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
state.result.url
} else {
null
}
} 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.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
if (sensitiveContent) sensitiveContent("")
}.build()
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
state.result.url
} else {
null
}
}
message = message.insertUrlAtCursor(urls.joinToString(" "))
urlPreview = findUrlInMessage()
message = message.insertUrlAtCursor(urls.joinToString(" "))
urlPreview = findUrlInMessage()
this@EditPostViewModel.multiOrchestrator = null
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
this@EditPostViewModel.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"))
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
} finally {
mediaUploadTracker.finishUpload()
}
mediaUploadTracker.finishUpload()
}
}
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.collections.immutable.ImmutableList
@@ -90,10 +91,11 @@ open class NewMediaModel : ViewModel() {
fun upload(
context: Context,
accountViewModel: AccountViewModel,
onSucess: () -> Unit,
onError: (String, String) -> Unit,
) = try {
uploadUnsafe(context, onSucess, onError)
uploadUnsafe(context, accountViewModel, onSucess, onError)
} catch (e: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
@@ -103,6 +105,7 @@ open class NewMediaModel : ViewModel() {
fun uploadUnsafe(
context: Context,
accountViewModel: AccountViewModel,
onSucess: () -> Unit,
onError: (String, String) -> Unit,
) {
@@ -155,10 +158,13 @@ open class NewMediaModel : ViewModel() {
}
}.toMap()
// Sign + publish via launchSigner so SignerExceptions surface
// through the standard toastManager pipeline (and timed-out /
// rejected prompts get logged instead of crashing the process).
val nip95jobs =
nip95s.map {
// upload each file as an individual nip95 event.
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.launchSigner {
val nip95 = myAccount.createNip95(it.bytes, headerInfo = it.fileHeader, caption, if (sensitiveContent) "" else null)
myAccount.consumeAndSendNip95(nip95.first, nip95.second)
}
@@ -166,9 +172,8 @@ open class NewMediaModel : ViewModel() {
val videoJobs =
videosAndOthers.map {
// upload each file as an individual nip95 event.
viewModelScope.launch(Dispatchers.IO) {
account?.sendHeader(
accountViewModel.launchSigner {
myAccount.sendHeader(
url = it.url,
magnetUri = it.magnet,
headerInfo = it.fileHeader,
@@ -182,8 +187,8 @@ open class NewMediaModel : ViewModel() {
val imageJobs =
if (imageUrls.isNotEmpty()) {
listOf(
viewModelScope.launch(Dispatchers.IO) {
account?.sendAllAsOnePictureEvent(
accountViewModel.launchSigner {
myAccount.sendAllAsOnePictureEvent(
urlHeaderInfo = imageUrls,
caption = caption,
contentWarningReason = if (sensitiveContent) "" else null,
@@ -110,7 +110,7 @@ fun NewMediaView(
onClose()
},
onPost = {
postViewModel.upload(context, onClose, accountViewModel.toastManager::toast)
postViewModel.upload(context, accountViewModel, onClose, accountViewModel.toastManager::toast)
postViewModel.selectedServer?.let {
account.settings.changeDefaultFileServer(it)
}
@@ -0,0 +1,328 @@
/*
* 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.components.namecoin
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/**
* Translate a [NamecoinResolveOutcome] from the quartz resolver into the
* shared [NamecoinResolveState] used elsewhere in the app (e.g. the
* desktop `SearchScreen`'s inline Namecoin lookup row).
*
* Mirrors the wording desktop already ships so the same identifier
* produces the same diagnostic string regardless of which surface
* triggered the lookup. Callers are expected to handle
* [NamecoinResolveOutcome.Success] separately (it needs a [User]
* lookup through [com.vitorpamplona.amethyst.model.LocalCache], which
* this helper has no access to).
*/
fun mapOutcomeToResolveState(outcome: NamecoinResolveOutcome): NamecoinResolveState =
when (outcome) {
is NamecoinResolveOutcome.Success ->
// Success is intentionally NOT handled here — callers must
// resolve the pubkey through LocalCache first.
error("mapOutcomeToResolveState called with Success outcome; resolve via LocalCache instead")
is NamecoinResolveOutcome.NameNotFound -> NamecoinResolveState.NotFound
is NamecoinResolveOutcome.NoNostrField ->
NamecoinResolveState.Error("${outcome.name} is registered but has no Nostr pubkey")
is NamecoinResolveOutcome.MalformedRecord ->
// Surface the parser detail verbatim so the publisher of the
// broken record can locate the bad byte
// (kotlinx.serialization includes a column number).
NamecoinResolveState.Error("${outcome.name} record is malformed: ${outcome.error}")
is NamecoinResolveOutcome.ServersUnreachable ->
NamecoinResolveState.Error("ElectrumX servers unreachable — check your connection or try again")
is NamecoinResolveOutcome.InvalidIdentifier ->
NamecoinResolveState.Error("Invalid Namecoin identifier")
NamecoinResolveOutcome.Timeout ->
NamecoinResolveState.Error("Resolution timed out — servers may be slow, try again")
}
/**
* Lightweight syntactic check: does this look like something we should
* route to Namecoin? Mirrors [com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver.isNamecoinIdentifier]
* but tolerates a leading `@` (matches the dropdown's [com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState.userSearchTermOrNull]).
*/
fun looksLikeNamecoinIdentifier(raw: String): Boolean {
val trimmed = raw.trim().removePrefix("@").lowercase()
if (trimmed.length < 5) return false
return trimmed.endsWith(".bit") ||
trimmed.contains("@") && trimmed.substringAfter('@').endsWith(".bit")
}
/**
* Inline Namecoin resolution indicator + result row. Designed to be
* mounted alongside any text input whose local-cache prefix search can
* race ahead of an on-chain `.bit` lookup (the onchain-zap recipient
* field and the global search bar both have this race).
*
* Behaviour:
* - Renders nothing when [searchInput] is not a `.bit` identifier.
* - Shows a small spinner row ("Resolving on Namecoin…") while the
* ElectrumX lookup is in flight (after a 300 ms debounce to match
* typical input-field debounce intervals).
* - On success, shows the resolved user as a tappable row with a
* `MaterialSymbols.Link` badge labelled "Namecoin"; tapping calls
* [onUserResolved].
* - On failure, shows a single explanatory line in the error colour.
*
* State is held in [NamecoinResolveState] (the same sealed class the
* desktop `SearchScreen` and `NamecoinNameService` already use) so this
* row stays in lockstep with the rest of the app's Namecoin UI.
*
* The composable is intentionally self-contained: it owns its own
* [LaunchedEffect] keyed on [searchInput], so it cancels in-flight
* lookups whenever the user keeps typing.
*
* @param modifier applied to the outer `Column` so callers can position
* or pad the row (e.g. the search bar pads horizontally).
*/
@Composable
fun NamecoinResolutionRow(
searchInput: String,
accountViewModel: AccountViewModel,
onUserResolved: (User) -> Unit,
modifier: Modifier = Modifier,
) {
val trimmed = remember(searchInput) { searchInput.trim().removePrefix("@") }
if (!looksLikeNamecoinIdentifier(trimmed)) return
var state by remember { mutableStateOf<NamecoinResolveState?>(null) }
LaunchedEffect(trimmed) {
// Match UserSuggestionState's 300 ms debounce so we don't fire a
// lookup on every keystroke.
delay(300)
state = NamecoinResolveState.Loading
val outcome =
withContext(Dispatchers.IO) {
runCatching {
Amethyst.instance.namecoinResolver.resolveDetailed(trimmed)
}.getOrElse {
NamecoinResolveOutcome.ServersUnreachable(
it.message ?: it::class.simpleName ?: "Lookup error",
)
}
}
state =
when (outcome) {
is NamecoinResolveOutcome.Success -> NamecoinResolveState.Resolved(outcome.result)
else -> mapOutcomeToResolveState(outcome)
}
}
Column(modifier = modifier) {
Spacer(Modifier.size(8.dp))
when (val s = state) {
null, NamecoinResolveState.Loading -> ResolvingChip(trimmed)
is NamecoinResolveState.Resolved -> ResolvedRow(trimmed, s, accountViewModel, onUserResolved)
NamecoinResolveState.NotFound -> FailedRow("No record for $trimmed on Namecoin.")
is NamecoinResolveState.Error -> FailedRow(s.message)
}
}
}
@Composable
private fun ResolvingChip(query: String) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Resolving $query on Namecoin…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun ResolvedRow(
query: String,
state: NamecoinResolveState.Resolved,
accountViewModel: AccountViewModel,
onUserResolved: (User) -> Unit,
) {
// Look up the User in the same cache the rest of the app uses, exactly
// the way desktop's SearchScreen does. Falls back to a malformed-record
// error row if the pubkey somehow fails the hex shape check.
val user =
remember(state.result.pubkey) {
accountViewModel.account.cache.checkGetOrCreateUser(state.result.pubkey)
}
if (user == null) {
FailedRow(
"${state.result.namecoinName} record is malformed: " +
"pubkey ${state.result.pubkey} is not a valid hex key",
)
return
}
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier =
Modifier
.fillMaxWidth()
.clickable { onUserResolved(user) },
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
UserPicture(
userHex = user.pubkeyHex,
size = 32.dp,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = user.toBestDisplayName(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = query,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
NamecoinBadge()
}
}
}
@Composable
private fun NamecoinBadge() {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = MaterialSymbols.Link,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(14.dp),
)
Text(
text = "Namecoin",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@Composable
private fun FailedRow(message: String) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(18.dp),
)
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.background(MaterialTheme.colorScheme.errorContainer),
)
}
}
}
@@ -0,0 +1,100 @@
/*
* 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.components.util
import android.widget.Toast
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
/**
* Text composable that opens [onClick] on a tap and copies [copyValue] to the
* system clipboard on a long-press (with a Toast confirmation).
*
* This is the long-press-to-copy counterpart of [com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary].
* It deliberately uses a plain [Text] + [combinedClickable] outer modifier
* rather than an inline `LinkAnnotation.Clickable` inside an `AnnotatedString`,
* because annotation-level clicks can't see a parent [combinedClickable]'s
* long-press: the parent's tap area sits above the annotation's hit-test
* region and would consume the tap before the annotation could fire it.
*
* @param displayText text shown to the user (may be a stripped form, e.g.
* "example.com" for a website value "https://example.com").
* @param copyValue raw value placed on the clipboard on long-press
* (typically the full, unmodified profile field).
* @param onClick tap action — usually "open the link" or "expand zap UI".
* @param toastResId string resource shown via [Toast.LENGTH_SHORT] after the
* value is placed on the clipboard. Defaults to a generic
* "Copied to clipboard" message.
* @param onLongClickLabelResId accessibility label exposed to TalkBack for
* the long-press action. Defaults to "Copy to clipboard".
*/
@Composable
fun LongPressCopyText(
displayText: String,
copyValue: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary,
style: TextStyle = LocalTextStyle.current,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Ellipsis,
maxLines: Int = Int.MAX_VALUE,
toastResId: Int = R.string.copied_to_clipboard,
onLongClickLabelResId: Int = R.string.copy_to_clipboard,
) {
val context = LocalContext.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val longClickLabel = stringRes(onLongClickLabelResId)
Text(
text = displayText,
color = color,
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
modifier =
modifier.combinedClickable(
onClick = onClick,
onLongClick = {
scope.launch {
clipboard.setText(copyValue)
Toast.makeText(context, stringRes(context, toastResId), Toast.LENGTH_SHORT).show()
}
},
onLongClickLabel = longClickLabel,
),
)
}
@@ -164,6 +164,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsSc
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MutedThreadsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen
@@ -326,6 +327,7 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.NotificationSettings> { NotificationSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ImportFollowsPickFollows> {
ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
@@ -239,6 +239,8 @@ sealed class Route {
@Serializable object CallSettings : Route()
@Serializable object NotificationSettings : Route()
@Serializable object Lists : Route()
@Serializable data class MyPeopleListView(
@@ -107,6 +107,23 @@ fun HiddenNotePreview() {
)
}
@Composable
@Preview
fun HiddenNoteExcessiveHashtagsPreview() {
ThemeComparisonColumn(
toPreview = {
HiddenNote(
reports = persistentSetOf(),
isHiddenAuthor = false,
hasExcessiveHashtags = true,
hashtagLimit = 8,
accountViewModel = mockAccountViewModel(),
nav = EmptyNav(),
) {}
},
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun HiddenNote(
@@ -115,8 +132,11 @@ fun HiddenNote(
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
nav: INav,
hasExcessiveHashtags: Boolean = false,
hashtagLimit: Int = 0,
onClick: () -> Unit,
) {
val hasReporters = isHiddenAuthor || reports.isNotEmpty()
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Row(
modifier = Modifier.padding(horizontal = 20.dp),
@@ -127,26 +147,42 @@ fun HiddenNote(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(30.dp),
) {
Text(
text = stringRes(R.string.post_was_flagged_as_inappropriate_by),
color = Color.Gray,
)
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
if (isHiddenAuthor) {
UserPicture(
user = accountViewModel.userProfile(),
size = Size35dp,
nav = nav,
accountViewModel = accountViewModel,
)
}
reports.forEach {
NoteAuthorPicture(
baseNote = it,
size = Size35dp,
accountViewModel = accountViewModel,
nav = nav,
)
if (hasExcessiveHashtags) {
Text(
text = stringRes(R.string.post_was_hidden_due_to_too_many_hashtags, hashtagLimit),
color = Color.Gray,
textAlign = TextAlign.Center,
)
}
if (hasReporters || !hasExcessiveHashtags) {
Text(
text = stringRes(R.string.post_was_flagged_as_inappropriate_by),
color = Color.Gray,
modifier =
if (hasExcessiveHashtags) {
Modifier.padding(top = 10.dp)
} else {
Modifier
},
)
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
if (isHiddenAuthor) {
UserPicture(
user = accountViewModel.userProfile(),
size = Size35dp,
nav = nav,
accountViewModel = accountViewModel,
)
}
reports.forEach {
NoteAuthorPicture(
baseNote = it,
size = Size35dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -77,11 +77,13 @@ fun WatchBlockAndReport(
normalNote(isHidden.canPreview)
} else {
HiddenNote(
isHidden.relevantReports,
isHidden.isHiddenAuthor,
accountViewModel,
modifier,
nav,
reports = isHidden.relevantReports,
isHiddenAuthor = isHidden.isHiddenAuthor,
hasExcessiveHashtags = isHidden.hasExcessiveHashtags,
hashtagLimit = isHidden.hashtagLimit,
accountViewModel = accountViewModel,
modifier = modifier,
nav = nav,
onClick = { showAnyway.value = true },
)
}
@@ -514,6 +514,8 @@ class AccountViewModel(
val canPreview: Boolean = true,
val isHiddenAuthor: Boolean = false,
val relevantReports: ImmutableSet<Note> = persistentSetOf(),
val hasExcessiveHashtags: Boolean = false,
val hashtagLimit: Int = 0,
)
fun isNoteAcceptable(
@@ -546,12 +548,16 @@ class AccountViewModel(
// No need to process reports if nothing is wrong
NoteComposeReportState(isPostHidden, isAcceptable = true, canPreview = true, isHiddenAuthor = false)
} else {
val hashtagLimit = account.maxHashtagLimit()
val hasExcessiveHashtags = account.hasExcessiveHashtags(note)
NoteComposeReportState(
isPostHidden,
newIsAcceptable,
newCanPreview,
false,
account.getRelevantReports(note).toImmutableSet(),
isPostHidden = isPostHidden,
isAcceptable = newIsAcceptable,
canPreview = newCanPreview,
isHiddenAuthor = false,
relevantReports = account.getRelevantReports(note).toImmutableSet(),
hasExcessiveHashtags = hasExcessiveHashtags,
hashtagLimit = hashtagLimit,
)
}
}
@@ -1061,7 +1067,7 @@ class AccountViewModel(
}
}
inline fun launchSigner(crossinline action: suspend () -> Unit) {
inline fun launchSigner(crossinline action: suspend () -> Unit) =
viewModelScope.launch(Dispatchers.IO) {
try {
action()
@@ -1100,7 +1106,6 @@ class AccountViewModel(
)
}
}
}
fun approveCommunityPost(
post: Note,
@@ -30,6 +30,8 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
class ChatroomListKnownFeedFilter(
@@ -111,7 +113,7 @@ class ChatroomListKnownFeedFilter(
newRelevantPublicMessages.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
val channelId = (oldNote.event as? ChannelMessageEvent)?.channelId()
val channelId = publicChannelIdOf(oldNote)
if (newNotePair.key == channelId) {
hasUpdated = true
if ((newNotePair.value.createdAt() ?: 0L) > (oldNote.createdAt() ?: 0L)) {
@@ -260,4 +262,18 @@ class ChatroomListKnownFeedFilter(
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
// Maps a note that represents a public chat row to its channel id. The
// representative note for a channel may be the channel's create event
// (id == channelId), a metadata update, or a message — match all three so
// an arriving ChannelMessageEvent replaces an existing placeholder
// metadata/create note for the same channel instead of duplicating it
// (which would yield the same LazyColumn key twice).
private fun publicChannelIdOf(note: Note): String? =
when (val event = note.event) {
is ChannelMessageEvent -> event.channelId()
is ChannelMetadataEvent -> event.channelId()
is ChannelCreateEvent -> event.id
else -> null
}
}
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.layouts.LocalDisappearingScaffoldPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
@@ -72,11 +73,13 @@ fun GitRepositoryOverview(
accountViewModel: AccountViewModel,
nav: INav,
) {
val scaffoldPadding = LocalDisappearingScaffoldPadding.current
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(scaffoldPadding)
.padding(horizontal = 12.dp, vertical = 16.dp),
verticalArrangement = SectionSpacing,
) {
@@ -31,11 +31,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
@@ -88,9 +89,11 @@ fun DisplayLNAddress(
Row(verticalAlignment = Alignment.CenterVertically) {
LightningAddressIcon(modifier = Size16Modifier, tint = BitcoinOrange)
ClickableTextPrimary(
text = lud16,
LongPressCopyText(
displayText = lud16,
copyValue = lud16,
onClick = { zapExpanded = !zapExpanded },
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
@@ -44,7 +44,6 @@ import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -59,10 +58,9 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.appendLink
import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.DrawPlayName
@@ -237,8 +235,9 @@ fun DrawAdditionalInfo(
modifier = Modifier.size(18.dp),
)
ClickableTextPrimary(
text = website.removePrefix("https://").removePrefix("http://").removeSuffix("/"),
LongPressCopyText(
displayText = website.removePrefix("https://").removePrefix("http://").removeSuffix("/"),
copyValue = website,
onClick = {
runCatching {
if (website.contains("://")) {
@@ -248,6 +247,7 @@ fun DrawAdditionalInfo(
}
}
},
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(vertical = 1.dp, horizontal = 5.dp),
)
}
@@ -264,9 +264,11 @@ fun DrawAdditionalInfo(
modifier = Modifier.size(18.dp),
)
ClickableTextPrimary(
text = identity.identity,
LongPressCopyText(
displayText = identity.identity,
copyValue = identity.identity,
onClick = { runCatching { uri.openUri(identity.toProofUrl()) } },
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 5.dp),
)
}
@@ -343,21 +345,18 @@ fun DisplayNip05ProfileStatus(
ObserveAndRenderNIP05VerifiedSymbol(nip05State, 2, Size15Modifier, accountViewModel)
val uri = LocalUriHandler.current
val color = MaterialTheme.colorScheme.primary
val displayValue = nip05State.nip05.toDisplayValue()
Text(
text =
remember(nip05State) {
buildAnnotatedString {
appendLink(nip05State.nip05.toDisplayValue(), color) {
runCatching { uri.openUri("https://${nip05State.nip05.domain}") }
}
}
},
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp),
LongPressCopyText(
displayText = displayValue,
copyValue = displayValue,
onClick = {
runCatching { uri.openUri("https://${nip05State.nip05.domain}") }
},
softWrap = true,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp),
)
}
}
@@ -419,13 +418,15 @@ fun PaymentTargetRow(target: PaymentTarget) {
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
modifier = Modifier.padding(end = 4.dp),
)
ClickableTextPrimary(
text = target.authority,
LongPressCopyText(
displayText = target.authority,
copyValue = target.authority,
onClick = {
runCatching {
uri.openUri("payto://${target.type}/${target.authority}")
}
},
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(vertical = 1.dp),
)
}
@@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.search.SearchSource
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
@@ -194,6 +195,24 @@ private fun SearchBar(
Column(modifier = Modifier.statusBarsPadding()) {
SearchTextField(searchBarViewModel, Modifier)
// Inline Namecoin lookup feedback for the global search field.
// Mirrors the wiring in OnchainZapSendDialog: the local prefix
// search can race ahead of the on-chain resolution and show a
// cached sibling profile (e.g. "m@testls.bit") before the bare
// ".bit" host resolves to its `_@host` profile. Surfaces the
// in-flight state, the eventual on-chain match, and any failure
// explicitly. Tapping the resolved row navigates to the user and
// clears the search field, matching the existing bech32 auto-
// resolve behaviour in `SearchBarViewModel.directRouteResolver`.
NamecoinResolutionRow(
searchInput = searchBarViewModel.searchValue,
accountViewModel = accountViewModel,
onUserResolved = { user ->
nav.nav(routeFor(user))
searchBarViewModel.clear()
},
modifier = Modifier.padding(horizontal = 10.dp),
)
SearchFilterRow(searchBarViewModel)
}
}
@@ -218,6 +218,12 @@ fun AllSettingsScreen(
onClick = { nav.nav(Route.Settings) },
)
SettingsDivider()
SettingsItem(
title = R.string.notification_settings,
icon = MaterialSymbols.Notifications,
onClick = { nav.nav(Route.NotificationSettings) },
)
SettingsDivider()
SettingsItem(
title = R.string.compose_settings,
icon = MaterialSymbols.Edit,
@@ -31,12 +31,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -46,14 +42,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.FeatureSetType
@@ -65,8 +58,6 @@ import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.parseFeatureSetType
import com.vitorpamplona.amethyst.model.parseGalleryType
import com.vitorpamplona.amethyst.model.parseThemeType
import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper
import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -97,7 +88,7 @@ fun SettingsScreen(
},
) {
Column(Modifier.padding(it)) {
SettingsScreen(accountViewModel.settings.uiSettingsFlow, accountViewModel)
SettingsScreen(accountViewModel.settings.uiSettingsFlow)
}
}
}
@@ -111,10 +102,7 @@ fun SettingsScreenPreview() {
}
@Composable
fun SettingsScreen(
sharedPrefs: UiSettingsFlow,
accountViewModel: AccountViewModel? = null,
) {
fun SettingsScreen(sharedPrefs: UiSettingsFlow) {
Column(
Modifier
.fillMaxSize()
@@ -128,18 +116,11 @@ fun SettingsScreen(
ShowImagePreviewChoice(sharedPrefs)
ShowVideoPlaybackChoice(sharedPrefs)
AutoplayVideosChoice(sharedPrefs)
if (BuildConfig.FLAVOR == "play") {
}
ShowUrlPreviewChoice(sharedPrefs)
ShowProfilePictureChoice(sharedPrefs)
ImmersiveScrollingChoice(sharedPrefs)
FeatureSetChoice(sharedPrefs)
GalleryChoice(sharedPrefs)
PushNotificationSettingsRow(sharedPrefs)
if (accountViewModel != null) {
AlwaysOnNotificationServiceChoice(accountViewModel)
SplitNotificationsChoice(accountViewModel)
}
}
}
@@ -489,86 +470,3 @@ fun SettingsRow(
}
}
}
@Composable
fun AlwaysOnNotificationServiceChoice(accountViewModel: AccountViewModel) {
val enabled by accountViewModel.account.settings.alwaysOnNotificationService
.collectAsStateWithLifecycle()
SettingsRow(
R.string.always_on_notif_setting_title,
R.string.always_on_notif_setting_description,
) {
Switch(
checked = enabled,
onCheckedChange = {
accountViewModel.account.settings.toggleAlwaysOnNotificationService()
},
)
}
if (enabled) {
BatteryOptimizationBanner()
}
}
@Composable
fun SplitNotificationsChoice(accountViewModel: AccountViewModel) {
val enabled by accountViewModel.account.settings.splitNotificationsEnabled
.collectAsStateWithLifecycle()
SettingsRow(
R.string.split_notifications_setting_title,
R.string.split_notifications_setting_description,
) {
Switch(
checked = enabled,
onCheckedChange = {
accountViewModel.account.settings.toggleSplitNotificationsEnabled()
},
)
}
}
@Composable
fun BatteryOptimizationBanner() {
val context = LocalContext.current
val isExempt =
remember {
BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context)
}
if (!isExempt) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringRes(R.string.battery_optimization_title),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = stringRes(R.string.battery_optimization_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Button(
onClick = {
BatteryOptimizationHelper.requestBatteryOptimizationExemption(context)
},
) {
Text(stringRes(R.string.battery_optimization_fix_now))
}
}
}
}
}
@@ -0,0 +1,276 @@
/*
* 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.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper
import com.vitorpamplona.amethyst.service.notifications.NotificationChannels
import com.vitorpamplona.amethyst.ui.components.PushNotificationProviderTile
import com.vitorpamplona.amethyst.ui.components.hasPushNotificationProvider
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Composable
fun NotificationSettingsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = { TopBarWithBackButton(stringRes(id = R.string.notification_settings), nav) },
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
DeliverySection(accountViewModel)
DisplaySection(accountViewModel)
CategoriesSection()
}
}
}
@Composable
private fun DeliverySection(accountViewModel: AccountViewModel) {
val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService
.collectAsStateWithLifecycle()
SettingsSection(R.string.notification_settings_section_delivery) {
if (hasPushNotificationProvider()) {
PushNotificationProviderTile(accountViewModel.settings.uiSettingsFlow)
SettingsDivider()
}
SettingsSwitchTile(
icon = MaterialSymbols.Notifications,
title = R.string.always_on_notif_setting_title,
description = R.string.always_on_notif_setting_description,
checked = alwaysOn,
onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() },
)
}
if (alwaysOn) {
BatteryOptimizationBanner()
}
}
@Composable
private fun DisplaySection(accountViewModel: AccountViewModel) {
val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled
.collectAsStateWithLifecycle()
SettingsSection(R.string.notification_settings_section_display) {
SettingsSwitchTile(
icon = MaterialSymbols.Forum,
title = R.string.split_notifications_setting_title,
description = R.string.split_notifications_setting_description,
checked = splitByFollows,
onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() },
)
}
}
@Composable
private fun CategoriesSection() {
val context = LocalContext.current
val entries = NotificationChannels.contentChannels
// Read each channel's importance after every resume so toggling
// sound/importance in the system page reflects back here. The map IS
// the state — no key-bump trick needed.
var statuses by remember {
mutableStateOf<Map<String, NotificationChannels.ChannelStatus>>(emptyMap())
}
LifecycleResumeEffect(Unit) {
statuses =
entries.associate {
val id = it.channelId(context)
id to NotificationChannels.statusOf(context, id)
}
onPauseOrDispose {}
}
SettingsSection(R.string.notification_settings_section_categories) {
entries.forEachIndexed { index, entry ->
if (index > 0) SettingsDivider()
val channelId = remember(entry) { entry.channelId(context) }
// Default to ON for channels not yet created — matches Android's
// own default importance, so the badge isn't misleading before the
// user has interacted with the channel.
val status = statuses[channelId] ?: NotificationChannels.ChannelStatus.ON
SettingsItem(
title = entry.nameRes,
icon = entry.icon,
trailing = { ChannelStatusBadge(status) },
onClick = {
// Lazy-create the channel right before opening so the system
// per-channel page has something to display; idempotent.
entry.ensure(context)
NotificationChannels.openChannelSettings(context, channelId)
},
)
}
}
Text(
text = stringRes(R.string.notification_settings_categories_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
@Composable
private fun ChannelStatusBadge(status: NotificationChannels.ChannelStatus) {
when (status) {
NotificationChannels.ChannelStatus.ON ->
StatusChip(
label = stringRes(R.string.notification_channel_status_on),
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
NotificationChannels.ChannelStatus.SILENT ->
StatusChip(
label = stringRes(R.string.notification_channel_status_silent),
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
NotificationChannels.ChannelStatus.OFF ->
StatusChip(
label = stringRes(R.string.notification_channel_status_off),
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
@Composable
private fun StatusChip(
label: String,
containerColor: Color,
contentColor: Color,
) {
Box(
modifier =
Modifier
.clip(RoundedCornerShape(50))
.background(containerColor)
.padding(horizontal = 10.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = contentColor,
)
}
}
@Composable
private fun BatteryOptimizationBanner() {
val context = LocalContext.current
var isExempt by remember {
mutableStateOf(BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context))
}
LifecycleResumeEffect(Unit) {
isExempt = BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context)
onPauseOrDispose {}
}
if (isExempt) return
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringRes(R.string.battery_optimization_title),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = stringRes(R.string.battery_optimization_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Button(
onClick = { BatteryOptimizationHelper.requestBatteryOptimizationExemption(context) },
) {
Text(stringRes(R.string.battery_optimization_fix_now))
}
}
}
}
@Preview
@Composable
fun NotificationSettingsScreenPreview() {
ThemeComparisonColumn {
NotificationSettingsScreen(mockAccountViewModel(), EmptyNav())
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -31,7 +30,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -40,7 +38,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.WarningType
import com.vitorpamplona.amethyst.model.parseWarningType
@@ -122,7 +119,7 @@ private fun FilterSpamTile(accountViewModel: AccountViewModel) {
.filterSpamFromStrangers
.collectAsStateWithLifecycle()
SwitchTile(
SettingsSwitchTile(
icon = MaterialSymbols.FilterAlt,
title = R.string.filter_spam_from_strangers_title,
description = R.string.filter_spam_from_strangers_explainer,
@@ -136,7 +133,7 @@ private fun HideCommunityViolationsTile(accountViewModel: AccountViewModel) {
val hideViolations by accountViewModel.account.settings.hideCommunityRulesViolations
.collectAsStateWithLifecycle()
SwitchTile(
SettingsSwitchTile(
icon = MaterialSymbols.Shield,
title = R.string.hide_community_rules_violations_title,
description = R.string.hide_community_rules_violations_explainer,
@@ -151,7 +148,7 @@ private fun WarnReportsTile(accountViewModel: AccountViewModel) {
val warnReports by security.warnAboutPostsWithReports.collectAsStateWithLifecycle()
val threshold by security.reportWarningThreshold.collectAsStateWithLifecycle()
SwitchTile(
SettingsSwitchTile(
icon = MaterialSymbols.Report,
title = R.string.warn_when_posts_have_reports_from_your_follows_title,
description = R.string.warn_when_posts_have_reports_from_your_follows_explainer,
@@ -194,24 +191,6 @@ private fun MaxHashtagsTile(accountViewModel: AccountViewModel) {
}
}
@Composable
private fun SwitchTile(
icon: MaterialSymbol,
@StringRes title: Int,
@StringRes description: Int,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
SettingsControlRow(
icon = icon,
title = stringRes(title),
description = stringRes(description),
onClick = { onCheckedChange(!checked) },
) {
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun BlockedContentSection(
accountViewModel: AccountViewModel,
@@ -38,6 +38,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -248,6 +249,25 @@ internal fun SettingsControlRow(
}
}
/** A [SettingsControlRow] whose trailing control is a [Switch]; tapping anywhere toggles. */
@Composable
internal fun SettingsSwitchTile(
icon: MaterialSymbol,
@StringRes title: Int,
@StringRes description: Int,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
SettingsControlRow(
icon = icon,
title = stringRes(title),
description = stringRes(description),
onClick = { onCheckedChange(!checked) },
) {
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
/**
* Sub-row variant of [SettingsControlRow]: indented in place of a leading icon,
* used for controls hierarchically grouped under the row above (e.g. a threshold
@@ -63,11 +63,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
@@ -158,10 +160,17 @@ fun OnchainZapSendDialog(
accountViewModel.zapAmountChoices()
}
// Mirror the dropdown's NIP-05 / Namecoin (.bit) resolution so Send can
// enable as soon as the typed name resolves, without forcing the user to
// tap the suggestion. Reuses the exact same path as the dropdown, so
// bare .bit names (e.g. testls.bit) and m@testls.bit both work.
val nip05Resolved by userSuggestions.nip05ResolutionFlow.collectAsStateWithLifecycle(initialValue = null)
val resolvedRecipient: HexKey? =
recipientPubKey
?: selectedUser?.pubkeyHex
?: searchInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) }
?: nip05Resolved?.pubkeyHex
val amountSats = amountInput.trim().toLongOrNull()
val canSend =
!sending &&
@@ -406,6 +415,18 @@ private fun RecipientSection(
modifier = Modifier.fillMaxWidth(),
)
// Inline Namecoin lookup feedback. Local-cache suggestions can race
// ahead of the on-chain resolution (especially when the user has
// resolved a sibling `user@<host>.bit` earlier in the session and the
// current query is the bare host), so we surface the in-flight state +
// the eventual on-chain match in its own row, distinct from the
// generic dropdown. Failures are surfaced here too.
NamecoinResolutionRow(
searchInput = searchInput,
accountViewModel = accountViewModel,
onUserResolved = onSelectUser,
)
if (searchInput.length > 2) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
@@ -359,6 +359,8 @@
<string name="quick_action_block_dialog_btn">Blokkeren</string>
<string name="quick_action_delete_dialog_btn">Verwijderen</string>
<string name="quick_action_block">Blokkeren</string>
<string name="quick_action_mute_thread">Discussie dempen</string>
<string name="quick_action_unmute_thread">Discussie dempen opheffen</string>
<string name="quick_action_report">Rapporteren</string>
<string name="quick_action_delete_button">Verwijderen</string>
<string name="quick_action_dont_show_again_button">Niet meer tonen</string>
@@ -634,6 +636,16 @@
<string name="nest_participant_unfollow">Ontvolgen</string>
<string name="nest_participant_mute">Dempen</string>
<string name="nest_participant_unmute">Dempen opheffen</string>
<string name="nest_force_mute_note">Kan genegeerd worden door clients die dit commando niet ondersteunen.</string>
<string name="nest_confirm_kick_title">Uit de ruimte kicken?</string>
<string name="nest_confirm_kick_body">%1$s wordt verwijderd van het audiokanaal en uit de deelnemerslijst. Ze kunnen opnieuw deelnemen als ze de link hebben.</string>
<string name="nest_confirm_kick_confirm">Kicken</string>
<string name="nest_confirm_force_mute_title">Spreker forceren te dempen?</string>
<string name="nest_confirm_force_mute_body">Vraagt %1$ss client om de microfoon te dempen. Sommige clients negeren dit commando mogelijk.</string>
<string name="nest_confirm_force_mute_confirm">Forceren dempen</string>
<string name="nest_confirm_cancel">Annuleren</string>
<string name="nest_toast_host_action_failed_title">Actie mislukt</string>
<string name="nest_toast_host_action_failed_template_null">De actie kon niet worden voorbereid.</string>
<string name="nest_share_action">Ruimte delen</string>
<string name="nest_minimize">Minimaliseren</string>
<string name="nest_minimize_description">Minimaliseren om te blijven luisteren</string>
@@ -1033,6 +1045,11 @@
<string name="call_failed_start">Kan gesprek niet starten</string>
<string name="call_failed_accept">Kan gesprek niet accepteren</string>
<string name="call_failed_session">Aanmaken oproepsessie mislukt</string>
<string name="call_permission_denied_title">Toestemming nodig</string>
<string name="call_permission_denied_voice">Amethyst heeft toegang tot de microfoon nodig om een spraakoproep te starten. Schakel dit in bij de app-instellingen.</string>
<string name="call_permission_denied_video">Amethyst heeft toegang tot camera en microfoon nodig om een video-oproep te starten. Schakel dit in bij de app-instellingen.</string>
<string name="call_permission_denied_open_settings">Instellingen openen</string>
<string name="call_permission_denied_cancel">Annuleren</string>
<string name="call_settings">Gespreksinstellingen</string>
<string name="call_settings_enable_calls">Spraak- en videogesprekken inschakelen</string>
<string name="call_settings_enable_calls_description">Wanneer uitgeschakeld, worden belknoppen verborgen en worden inkomende gesprekken stil genegeerd.</string>
@@ -1054,6 +1071,10 @@
<string name="always_on_notif_connecting">Verbinden met inbox-relays…</string>
<string name="always_on_notif_setting_title">Altijd-aan meldingsdienst</string>
<string name="always_on_notif_setting_description">Houdt een persistente verbinding met je inbox-relays voor directe melding. Toont een permanente notificatie. Gebruikt meer batterij maar zorgt dat je nooit een bericht mist.</string>
<string name="split_notifications_setting_title">Meldingen splitsen per gevolgden</string>
<string name="split_notifications_setting_description">Toon twee meldings-tabbladen — Volgend (mensen die je volgt) en Iedereen. De ongelezen-indicator licht alleen op voor activiteit van mensen die je volgt.</string>
<string name="notification_tab_following">Volgend</string>
<string name="notification_tab_everyone">Iedereen</string>
<string name="battery_optimization_title">Batterij-optimalisatie actief</string>
<string name="battery_optimization_description">Android kan relay-verbindingen op de achtergrond beperken. Schakel batterij-optimalisatie uit voor Amethyst voor betrouwbare meldingen.</string>
<string name="battery_optimization_fix_now">Nu oplossen</string>
@@ -1078,14 +1099,24 @@
<string name="warn_when_posts_have_reports_from_your_follows">Waarschuwen bij rapportages van volgers</string>
<string name="filter_spam_from_strangers_title">Spamfilter</string>
<string name="filter_spam_from_strangers_explainer">Verbergt identieke berichten van onbekenden die 5 keer of vaker voorkomen</string>
<string name="add_client_tag_title">Client-tag toevoegen aan mijn events</string>
<string name="add_client_tag_explainer">Wanneer ingeschakeld voegt Amethyst een NIP-89 client-tag toe aan events die je publiceert.</string>
<string name="warn_when_posts_have_reports_from_your_follows_title">Waarschuwen bij rapportages</string>
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Toont waarschuwing wanneer een bericht 5 of meer rapportages van je volgers heeft</string>
<string name="report_warning_threshold_title">Drempel voor rapportage-waarschuwing</string>
<string name="report_warning_threshold_explainer">Toont waarschuwing wanneer berichten of profielen dit aantal rapportages van mensen die je volgt bereiken</string>
<string name="show_sensitive_content_title">Gevoelige inhoud tonen</string>
<string name="show_sensitive_content_explainer">Toont waarschuwing wanneer auteur inhoud als gevoelig heeft gemarkeerd</string>
<string name="max_hashtag_limit_title">Maximum hashtags per bericht</string>
<string name="max_hashtag_limit_explainer">Verbergt berichten met meer hashtags dan deze limiet. Stel in op 0 om uit te schakelen.</string>
<string name="hide_community_rules_violations_title">Berichten verbergen die community-regels schenden</string>
<string name="hide_community_rules_violations_explainer">Verwijdert berichten uit community-feeds wanneer de community een NIP-9B regelsdocument publiceert en een event daartegen zou falen. Heeft geen effect wanneer een community geen gestructureerde regels heeft.</string>
<string name="security_section_filtering_preferences">Filtervoorkeuren</string>
<string name="security_section_blocked_content">Geblokkeerde inhoud</string>
<string name="security_unlimited"></string>
<string name="security_blocked_users_empty">Je hebt nog geen gebruikers geblokkeerd.</string>
<string name="security_spamming_users_empty">Geen accounts zijn in deze sessie als spam gemarkeerd.</string>
<string name="security_hidden_words_empty">Geen verborgen woorden. Voeg hieronder een woord toe om berichten met dat woord te verbergen.</string>
<string name="new_reaction_symbol">Nieuw reactie-symbool</string>
<string name="no_reaction_type_setup_long_press_to_change">Geen reactietypes ingesteld. Houd ingedrukt om te wijzigen.</string>
<string name="zapraiser">Zapraiser</string>
@@ -1263,6 +1294,7 @@
<string name="spamming_users">Spammers</string>
<string name="muted_button">Gedempt. Tik voor geluid</string>
<string name="mute_button">Geluid aan. Tik voor dempen</string>
<string name="action_unmute">Demping opheffen</string>
<string name="skip_back">%d seconden terug</string>
<string name="skip_forward">%d seconden vooruit</string>
<string name="picture_in_picture">Picture-in-Picture</string>
@@ -1282,6 +1314,8 @@
<string name="geohash_exclusive_explainer">Alleen volgers van de locatie zien dit bericht.</string>
<string name="hashtag_exclusive">Alleen hashtag-exclusief bericht</string>
<string name="hashtag_exclusive_explainer">Alleen volgers van de hashtag zien dit bericht.</string>
<string name="external_url_scope">Reageer op een website</string>
<string name="external_id_scope">Reageer op een externe bron</string>
<string name="long_form_reading_minutes">%1$d min lezen</string>
<string name="loading_location">Locatie laden…</string>
<string name="lack_location_permissions">Geen locatiemachtigingen</string>
@@ -1378,6 +1412,9 @@
<string name="no_blossom_apps_found_description">Geen Blossom-app gevonden. Installeer een lokale Blossom-app om dit bestand te bekijken.</string>
<string name="hidden_words">Verborgen woorden</string>
<string name="hide_new_word_label">Nieuwe woorden of zinnen verbergen</string>
<string name="settings_muted_threads_title">Gedempte discussies</string>
<string name="settings_muted_threads_empty">Geen gedempte discussies</string>
<string name="settings_muted_threads_unknown">Onbekende discussie · %1$s</string>
<string name="automatically_show_profile_picture">Profielfoto</string>
<string name="automatically_show_profile_picture_description">Profielfoto\'s tonen</string>
<string name="select_an_option">Selecteer een optie</string>
@@ -1607,6 +1644,12 @@
<string name="home_tabs_settings">Start-tabbladen</string>
<string name="home_tabs_settings_description">Kies welke tabbladen op het startscherm verschijnen. Wanneer slechts één tab actief is, wordt de tabbalk verborgen.</string>
<string name="home_tab_everything">Alles</string>
<string name="profile_ui_settings">Profiel-weergave</string>
<string name="profile_ui_settings_description">Kies welke secties en feeds op gebruikersprofielschermen verschijnen. Standaard zijn alle opties ingeschakeld.</string>
<string name="profile_ui_setting_badges">Profielbadges</string>
<string name="profile_ui_setting_app_recommendations">App-aanbevelingen</string>
<string name="profile_ui_setting_zap_received_feed">Ontvangen zaps-feed</string>
<string name="profile_ui_setting_followers_feed">Volgers-feed</string>
<string name="reactions_settings">Reactierij</string>
<string name="reactions_settings_description">Configureer welke reactieknoppen worden getoond, hun volgorde en of tellers worden weergegeven.</string>
<string name="reactions_settings_enabled">Ingeschakeld</string>
@@ -1641,6 +1684,8 @@
<string name="video_player_settings_action_download_description">Video downloaden naar je apparaat (verborgen bij livestreams)</string>
<string name="video_player_settings_action_pip">Picture-in-Picture</string>
<string name="video_player_settings_action_pip_description">Video in zwevend venster afspelen (verborgen als niet ondersteund)</string>
<string name="video_player_settings_action_cast">Casten naar apparaat</string>
<string name="video_player_settings_action_cast_description">Video casten naar een Chromecast-ontvanger op je wifi (verborgen bij lokale bestanden)</string>
<string name="profile_image_of_user">Profielfoto van %1$s</string>
<string name="relay_info">Relay %1$s</string>
<string name="expand_relay_list">Relay-lijst uitvouwen</string>
@@ -1759,6 +1804,18 @@
<string name="git_repository">Git-repository: %1$s</string>
<string name="git_web_address">Web:</string>
<string name="git_clone_address">Klonen:</string>
<string name="git_status_open">Openen</string>
<string name="git_status_merged">Samengevoegd</string>
<string name="git_status_closed">Gesloten</string>
<string name="git_status_draft">Concept</string>
<string name="git_repo_tab_overview">Overzicht</string>
<string name="git_repo_tab_issues">Problemen</string>
<string name="git_repo_tab_patches">Patches &amp; PRs</string>
<string name="git_repo_section_about">Over</string>
<string name="git_repo_section_links">Links</string>
<string name="git_repo_section_maintainers">Beheerders</string>
<string name="git_repo_section_topics">Onderwerpen</string>
<string name="git_repo_personal_fork">Persoonlijke fork</string>
<string name="nsite_title">Statische website: %1$s</string>
<string name="nsite_root_site">Root-site</string>
<string name="nsite_source">Bron:</string>
@@ -1856,8 +1913,11 @@
<string name="favorite_dvms_title">Favoriete feed-algoritmes</string>
<string name="favorite_dvms_explainer">Feed-algoritmes die je hier hebt gesterd, verschijnen als filterchips op de startfeed. Open Ontdekken om meer toe te voegen.</string>
<string name="favorite_dvms_empty_headline">Pin je favoriete algoritmes</string>
<string name="favorite_dvms_empty_step1">Tik op “%1$s” hieronder om algoritmes te bekijken.</string>
<!-- %1$s is replaced at runtime by an inline star icon, not text. Keep the placeholder. -->
<string name="favorite_dvms_empty_step2">Tik op het %1$s naast een feed om hem hier te bewaren.</string>
<string name="favorite_dvms_empty_cta">Feeds toevoegen</string>
<string name="favorite_dvms_add_more">Meer toevoegen…</string>
<string name="dvm_home_status_requesting">%1$s vragen voor een feed…</string>
<string name="dvm_home_status_requesting_all">Je favoriete feed-algoritmes vragen voor feeds…</string>
<string name="dvm_home_status_processing">Je feed verwerken…</string>
@@ -2250,6 +2310,10 @@
<string name="playback_actions_dialog_title">Afspelen</string>
<string name="video_quality_auto">Auto</string>
<!-- LAN cast (Chromecast) feature -->
<string name="cast_to_device">Casten naar apparaat</string>
<string name="cast_stop_casting">Casten stoppen</string>
<string name="cast_to_device_dialog_title">Casten naar…</string>
<string name="cast_searching_for_devices">Zoeken naar apparaten op je wifi…</string>
<!-- HLS multi-resolution video sharing -->
<string name="share_hls_video">HLS-upload</string>
<string name="share_hls_video_drawer_description">Publiceer multi-resolutie HLS naar je mediaserver</string>
@@ -2448,6 +2512,9 @@
<string name="ai_writing_setting_description">Gebruikt een on-device AI-model om tekstcorrecties en toonwijzigingen voor te stellen.</string>
<string name="tracked_broadcasts_setting_title">Getrackte uitzendingen</string>
<string name="tracked_broadcasts_setting_description">Gebruik de tracked broadcaster bij het verzenden van events. Toont live voortgang en per-relay-status tijdens uitzenden.</string>
<string name="compose_settings">Opstel-instellingen</string>
<string name="auto_create_drafts_setting_title">Automatisch concepten aanmaken</string>
<string name="auto_create_drafts_setting_description">Slaat automatisch een concept op wanneer je typt of de opsteller verlaat met onverzonden tekst en stuurt dit naar je privé outbox-relays.</string>
<string name="ai_writing_use_this">Gebruik dit</string>
<string name="ai_writing_dismiss">Sluiten</string>
<string name="ai_tone_correct">Corrigeren</string>
+13 -2
View File
@@ -10,6 +10,7 @@
<string name="show_anyway">Show Anyway</string>
<string name="post_was_hidden">This post was hidden because it mentions your hidden users or words</string>
<string name="post_was_flagged_as_inappropriate_by">Post was muted or reported by</string>
<string name="post_was_hidden_due_to_too_many_hashtags">This post has more than %1$d hashtags</string>
<string name="post_not_found">Event is loading or can\'t be found in your relay list</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Channel Image</string>
@@ -1541,6 +1542,7 @@
<string name="copy_stack_to_clipboard">Copy Stack</string>
<string name="copy_to_clipboard">Copy to clipboard</string>
<string name="copied_to_clipboard">Copied to clipboard</string>
<string name="copy_nprofile_to_clipboard">Copy nprofile to clipboard</string>
<string name="copy_npub_to_clipboard">Copy npub to clipboard</string>
<string name="share_or_save">Share or Save</string>
@@ -1674,9 +1676,18 @@
<string name="read_only_user">Read-only user</string>
<string name="no_reactions_setup">No reactions setup</string>
<string name="notification_settings">Notifications</string>
<string name="notification_settings_section_delivery">Delivery</string>
<string name="notification_settings_section_display">In-app display</string>
<string name="notification_settings_section_categories">Categories</string>
<string name="notification_settings_categories_explainer">Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there.</string>
<string name="notification_channel_status_on">On</string>
<string name="notification_channel_status_silent">Silent</string>
<string name="notification_channel_status_off">Off</string>
<string name="select_push_server">Select a UnifiedPush App</string>
<string name="push_server_title">Push Notification</string>
<string name="push_server_explainer">From installed UnifiedPush apps</string>
<string name="push_server_title">Push provider</string>
<string name="push_server_explainer">Pick a UnifiedPush app to deliver notifications when Amethyst is closed.</string>
<string name="push_server_none">None</string>
<string name="push_server_none_explainer">Disables Push Notifications</string>
<string name="push_server_uses_app_explainer">Uses app %1$s</string>
@@ -52,4 +52,6 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
}
@Composable
fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {}
fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {}
fun hasPushNotificationProvider(): Boolean = false
@@ -0,0 +1,164 @@
/*
* 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.components.namecoin
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class NamecoinResolutionRowTest {
// ── looksLikeNamecoinIdentifier ────────────────────────────────────────
@Test
fun `bare bit hostname is a namecoin identifier`() {
assertTrue(looksLikeNamecoinIdentifier("testls.bit"))
assertTrue(looksLikeNamecoinIdentifier("Example.BIT"))
}
@Test
fun `user at bit hostname is a namecoin identifier`() {
assertTrue(looksLikeNamecoinIdentifier("m@testls.bit"))
assertTrue(looksLikeNamecoinIdentifier("ALICE@example.BIT"))
}
@Test
fun `leading at sign is tolerated like the dropdown does`() {
assertTrue(looksLikeNamecoinIdentifier("@testls.bit"))
assertTrue(looksLikeNamecoinIdentifier("@m@testls.bit"))
}
@Test
fun `dns nip05 is not a namecoin identifier`() {
assertFalse(looksLikeNamecoinIdentifier("alice@example.com"))
assertFalse(looksLikeNamecoinIdentifier("example.com"))
}
@Test
fun `npub-shaped input is not a namecoin identifier`() {
assertFalse(looksLikeNamecoinIdentifier("npub1w90qq8jq8x0z6nyz3vqgsk9vnp0w9p4ldwc0wq4xv2n7v8jch2nq3p6wrx"))
}
@Test
fun `short or empty input is not a namecoin identifier`() {
// Below the minimum length threshold the dropdown also uses
// (UserSuggestionState.userSearchTermOrNull requires >2 chars).
assertFalse(looksLikeNamecoinIdentifier(""))
assertFalse(looksLikeNamecoinIdentifier(".bit"))
}
@Test
fun `single-label bit name is still considered a namecoin identifier`() {
// Namecoin allows single-character labels; "a.bit" is a valid
// (if expensive) registration. Don't filter it out client-side.
assertTrue(looksLikeNamecoinIdentifier("a.bit"))
}
@Test
fun `bit substring elsewhere does not trigger`() {
// "habit" or "rabbit.example.com" shouldn't match.
assertFalse(looksLikeNamecoinIdentifier("rabbit.example.com"))
assertFalse(looksLikeNamecoinIdentifier("ihabit"))
}
@Test
fun `at without bit suffix does not match`() {
// "foo@bar" with no .bit on the right side should not match.
assertFalse(looksLikeNamecoinIdentifier("foo@bar"))
assertFalse(looksLikeNamecoinIdentifier("foo@bar.com"))
}
// ── mapOutcomeToResolveState ───────────────────────────────────────────
// Reuses the shared NamecoinResolveState already used by
// NamecoinNameService and the desktop SearchScreen so all surfaces
// produce the same diagnostic strings for the same outcome.
@Test
fun `name not found maps to NotFound`() {
val state = mapOutcomeToResolveState(NamecoinResolveOutcome.NameNotFound("d/testls"))
assertSame(NamecoinResolveState.NotFound, state)
}
@Test
fun `no nostr field maps to Error and mentions the name`() {
val state = mapOutcomeToResolveState(NamecoinResolveOutcome.NoNostrField("d/noname"))
require(state is NamecoinResolveState.Error)
assertTrue(state.message.contains("d/noname"))
assertTrue(state.message.contains("Nostr"))
}
@Test
fun `malformed record preserves underlying parser detail`() {
val state =
mapOutcomeToResolveState(
NamecoinResolveOutcome.MalformedRecord(
"d/broken",
"Unfinished JSON term at EOF at line 1, column 474",
),
)
require(state is NamecoinResolveState.Error)
assertTrue(state.message.contains("d/broken"))
assertTrue(state.message.contains("Unfinished JSON"))
}
@Test
fun `servers unreachable maps to a generic Error`() {
val state =
mapOutcomeToResolveState(NamecoinResolveOutcome.ServersUnreachable("Connection refused"))
require(state is NamecoinResolveState.Error)
assertTrue(state.message.contains("ElectrumX"))
}
@Test
fun `invalid identifier maps to a generic Error`() {
val state =
mapOutcomeToResolveState(NamecoinResolveOutcome.InvalidIdentifier("not_a_name"))
require(state is NamecoinResolveState.Error)
assertEquals("Invalid Namecoin identifier", state.message)
}
@Test
fun `timeout maps to a timeout Error`() {
val state = mapOutcomeToResolveState(NamecoinResolveOutcome.Timeout)
require(state is NamecoinResolveState.Error)
assertTrue(state.message.contains("timed out"))
}
@Test(expected = IllegalStateException::class)
fun `success outcome must be handled by callers, not mapOutcomeToResolveState`() {
// mapOutcomeToResolveState is documented as failure-only; callers must
// route NamecoinResolveOutcome.Success through LocalCache themselves.
mapOutcomeToResolveState(
NamecoinResolveOutcome.Success(
NamecoinNostrResult(
pubkey = "deadbeef".repeat(8),
relays = emptyList(),
namecoinName = "d/testls",
localPart = "_",
),
),
)
}
}
@@ -104,13 +104,20 @@ private fun bridgeUrl(
if (url.startsWith("blossom:", ignoreCase = true)) return url
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
val sha =
explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) }
?: extractSha256FromUrlPath(url)
?: return url
// The local Blossom cache fetches `<xs>/<sha>.<ext>` on miss per BUD-01,
// which only works when the upstream URL is itself BUD-01 layout. For
// non-BUD-01 URLs (e.g. https://i.nostr.build/M5AwJ.gif) the imeta `x`
// hash identifies the blob but the upstream server doesn't host it at
// /<sha>.<ext>, so trusting only `explicitHash` would point the cache
// at a 404. Require the sha to be in the URL path before bridging.
val urlSha = extractSha256FromUrlPath(url) ?: return url
// Prefer the imeta hash when it's a valid sha256 (authoritative casing),
// otherwise fall back to what was parsed from the URL.
val sha = explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) } ?: urlSha
val ext = guessExtension(url, mimeType)
val serverBase = extractServerBase(url, sha) ?: return url
val serverBase = extractServerBase(url, urlSha) ?: return url
val authors =
authorPubKey
@@ -80,14 +80,15 @@ class MediaUrlContentExtTest {
@Test
fun bridgeOnInfersExtensionFromMimeType() {
val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha, mimeType = "image/png")
// BUD-01 allows `<sha>` without an extension; mimeType supplies one.
val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha, mimeType = "image/png")
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.png?xs="), "expected png extension from mime, got $result")
}
@Test
fun bridgeOnFallsBackToBinExtension() {
val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha)
val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.bin?xs="), "expected bin extension fallback, got $result")
}
@@ -100,7 +101,7 @@ class MediaUrlContentExtTest {
@Test
fun uppercaseHashNormalisedToLowercase() {
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = sha.uppercase())
val image = MediaUrlImage(url = "https://cdn.example.com/${sha.uppercase()}.jpg", hash = sha.uppercase())
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result")
}
@@ -110,7 +111,7 @@ class MediaUrlContentExtTest {
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
val image =
MediaUrlImage(
url = "https://cdn.example.com/foo.jpg",
url = "https://cdn.example.com/$sha.jpg",
hash = sha,
authorPubKey = authorPub,
)
@@ -122,7 +123,7 @@ class MediaUrlContentExtTest {
fun invalidAuthorPubKeyDropped() {
val image =
MediaUrlImage(
url = "https://cdn.example.com/foo.jpg",
url = "https://cdn.example.com/$sha.jpg",
hash = sha,
authorPubKey = "not-a-pubkey",
)
@@ -130,6 +131,24 @@ class MediaUrlContentExtTest {
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", result)
}
@Test
fun bridgeOnSkipsNonBud01UrlEvenWithImetaHash() {
// The imeta `x` hash refers to a blob whose canonical Blossom location
// is /<sha>.<ext>, but the upstream URL serves it under a different
// path (https://i.nostr.build/M5AwJ.gif). Routing this through the
// local cache would set xs=https://i.nostr.build, and the cache would
// fetch https://i.nostr.build/<sha>.gif on miss, which 404s.
val url = "https://i.nostr.build/M5AwJ.gif"
val image = MediaUrlImage(url = url, hash = sha)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeProfilePictureUrlSkipsNonBud01UrlEvenWithImetaHash() {
val url = "https://i.nostr.build/M5AwJ.gif"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test
fun bridgeProfilePictureUrlNullReturnsNull() {
assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true))
+13 -1
View File
@@ -91,6 +91,9 @@ compose.desktop {
jvmArgs += "-Xmx2g"
// VLC plugin path fallback — used if JNA setenv and bundled discovery both fail
jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins"
// Forward platform-preview overrides from the gradle invocation to the
// launched app's JVM so `./gradlew :desktopApp:run -Damethyst.platform=GNOME`
// works in addition to the env-var form (`AMETHYST_PLATFORM=GNOME`).
@@ -101,7 +104,15 @@ compose.desktop {
nativeDistributions {
appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources"))
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm)
modules("java.management") // Required by kmp-tor TorRuntime
// Output of ./gradlew suggestRuntimeModules (+ java.management already present)
modules(
"java.instrument", // Runtime instrumentation (agent/profiler hooks)
"java.management", // Required by kmp-tor TorRuntime
"java.prefs", // java.util.prefs (desktop persistence)
"java.sql", // JDBC metadata (Jackson, SQLite driver)
"jdk.security.auth", // JAAS authentication callbacks
"jdk.unsupported", // sun.misc.Unsafe (VLCJ ByteBufferFactory)
)
packageName = "Amethyst"
packageVersion = appVersion
@@ -143,6 +154,7 @@ compose.desktop {
// whose declared return type the JVM verifier rejects (R8 doesn't hit
// this — it generates bridges differently from ProGuard).
buildTypes.release.proguard {
version.set("7.9.1") // Kotlin 2.3 metadata support
configurationFiles.from(project.file("compose-rules.pro"))
}
}
+16
View File
@@ -96,6 +96,16 @@
native <methods>;
}
# kmp-tor — loads native Tor daemon via JNI reflection
-keep class io.matthewnelson.** { *; }
# Coil image loader — uses ServiceLoader for decoder/fetcher registration
-keep class coil3.** { *; }
# OkHttp/Okio — platform detection and I/O via reflection
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
# ============================================================================
# Optimize sub-pass — disable the one that produces invalid okio bytecode
# ============================================================================
@@ -185,3 +195,9 @@
# to detect logging. We ship slf4j-nop; keep it intact so detection succeeds.
-keep class org.slf4j.** { *; }
-dontwarn org.slf4j.**
# ============================================================================
# Kotlin 2.3 stdlib stubs compile-time classes with no JVM runtime class
# ============================================================================
-dontwarn kotlin.concurrent.atomics.**
-dontwarn kotlin.jvm.internal.EnhancedNullability
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.desktop.service.media
import com.sun.jna.Function
import com.sun.jna.NativeLibrary
import uk.co.caprica.vlcj.binding.lib.LibC
import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil
import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy
@@ -36,6 +36,14 @@ class MacOsVlcDiscoverer :
arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"),
arrayOf("%s/plugins"),
) {
/** Plugin path discovered during [setPluginPath], available after discovery. */
var discoveredPluginPath: String? = null
private set
/** Whether [setPluginPath] successfully set the process env var. */
var envVarSet: Boolean = false
private set
override fun supported(): Boolean {
val os = System.getProperty("os.name").lowercase()
return "mac" in os
@@ -52,5 +60,22 @@ class MacOsVlcDiscoverer :
return true
}
override fun setPluginPath(pluginPath: String?): Boolean = LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0
override fun setPluginPath(pluginPath: String?): Boolean {
if (pluginPath == null) return false
discoveredPluginPath = pluginPath
return try {
// Call setenv directly via JNA Function API. This bypasses vlcj's
// LibC interface binding which fails on macOS 13+ because dlsym
// can't resolve the versioned symbol `setenv$3b99ba0d`.
val setenv = Function.getFunction("c", "setenv")
val result = setenv.invokeInt(arrayOf<Any>(PLUGIN_ENV_NAME, pluginPath, 1)) == 0
envVarSet = result
result
} catch (_: Throwable) {
// JNA Function call also failed — VlcjPlayerPool will use
// --plugin-path factory arg as fallback.
envVarSet = false
false
}
}
}
@@ -55,6 +55,9 @@ object VlcjPlayerPool {
private val idleThumbPlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
private const val MAX_THUMB_POOL_SIZE = 2
// Cached plugin path for audio factory creation (set during init)
private var cachedPluginPath: String? = null
// Audio player pool (shared factory with --no-video)
private var audioFactory: MediaPlayerFactory? = null
private val allAudioPlayers = mutableListOf<MediaPlayer>()
@@ -76,12 +79,13 @@ object VlcjPlayerPool {
return try {
// Try bundled VLC first, then fall through to system VLC
val macOsDiscoverer = MacOsVlcDiscoverer()
val discovery =
try {
val nd =
NativeDiscovery(
BundledVlcDiscoverer(),
MacOsVlcDiscoverer(),
macOsDiscoverer,
)
val found = nd.discover()
if (found) {
@@ -99,7 +103,34 @@ object VlcjPlayerPool {
val systemDiscovery = NativeDiscovery().discover()
println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}")
}
val f = MediaPlayerFactory("--no-xlib")
// Delete stale VLC plugin cache on macOS to avoid spam warnings
if ("mac" in System.getProperty("os.name").lowercase()) {
try {
val cacheDir = java.io.File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc")
cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() }
} catch (_: Throwable) {
// Best-effort cache cleanup
}
}
// Build factory args — add --plugin-path fallback if env var wasn't set
val factoryArgs = mutableListOf("--no-xlib")
if (!macOsDiscoverer.envVarSet) {
val pluginPath =
macOsDiscoverer.discoveredPluginPath
?: System.getProperty("vlc.plugin.path")
?: VlcResourceResolver.findVlcDir()?.let { "${it.absolutePath}/plugins" }
if (pluginPath != null) {
factoryArgs += "--plugin-path=$pluginPath"
println("VLC: using --plugin-path fallback: $pluginPath")
}
}
cachedPluginPath = macOsDiscoverer.discoveredPluginPath
?: System.getProperty("vlc.plugin.path")
val f = MediaPlayerFactory(*factoryArgs.toTypedArray())
factory = f
available.set(true)
println("VLC: MediaPlayerFactory created successfully")
@@ -184,7 +215,9 @@ object VlcjPlayerPool {
val af =
audioFactory ?: try {
MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it }
val audioArgs = mutableListOf("--no-video", "--no-xlib")
cachedPluginPath?.let { audioArgs += "--plugin-path=$it" }
MediaPlayerFactory(*audioArgs.toTypedArray()).also { audioFactory = it }
} catch (_: Throwable) {
return null
}
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
@@ -50,7 +51,10 @@ class RelayAuthenticator(
val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
val signWithAllLoggedInUsers: suspend (EventTemplate<RelayAuthEvent>) -> List<RelayAuthEvent>,
) : IAuthStatus {
private val authStatus = mutableMapOf<NormalizedRelayUrl, RelayAuthStatus>()
// Connection callbacks fire on the per-relay OkHttp dispatcher thread, so
// this state is mutated concurrently — LargeCache wraps a platform-tuned
// concurrent map (ConcurrentSkipListMap on jvmAndroid, CacheMap on Apple).
private val authStatus = LargeCache<NormalizedRelayUrl, RelayAuthStatus>()
private val clientListener =
object : RelayConnectionListener {
@@ -66,7 +70,7 @@ class RelayAuthenticator(
}
override fun onConnecting(relay: IRelayClient) {
authStatus[relay.url] = RelayAuthStatus()
authStatus.put(relay.url, RelayAuthStatus())
}
override fun onDisconnected(relay: IRelayClient) {
@@ -82,7 +86,7 @@ class RelayAuthenticator(
val ev = RelayAuthEvent.build(relay.url, msg.challenge)
signWithAllLoggedInUsers(ev).forEach { authEvent ->
// only send replies to new challenges to avoid infinite loop:
if (authStatus[relay.url]?.saveAuthSubmission(authEvent) == true) {
if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) {
relay.sendIfConnected(AuthCmd(authEvent))
}
}
@@ -94,12 +98,12 @@ class RelayAuthenticator(
msg: OkMessage,
) {
// if this is the OK of an auth event, renew all subscriptions and resend all outgoing events.
if (authStatus[relay.url]?.checkAuthResults(msg.eventId, msg.success) == true) {
if (authStatus.get(relay.url)?.checkAuthResults(msg.eventId, msg.success) == true) {
client.syncFilters(relay)
}
}
override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = authStatus[relay]?.hasFinishedAllAuths() != false
override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = authStatus.get(relay)?.hasFinishedAllAuths() != false
init {
Log.d("RelayAuthenticator", "Init, Subscribe")
@@ -110,6 +110,26 @@ val DEFAULT_ELECTRUMX_SERVERS =
// nmc2.bitcoins.sk) so resolvers that have an unhealthy DNS path can
// still reach the server. Cert pin works by SHA-256 of DER, no SNI required.
ElectrumxServer("23.158.233.10", 50002, useSsl = true, usePinnedTrustStore = true),
// electrum.nmc.ethicnology.com — third public Namecoin ElectrumX deployment,
// operated by @ethicnology (github.com/ethicnology/namecoin-compose, a
// namecoind + ElectrumX + mempool podman stack). ElectrumX 1.19.0,
// Namecoin mainnet genesis (000000000062b72c…c770).
//
// Uses a publicly-trusted Let's Encrypt certificate, so usePinnedTrustStore
// is left at the default (false) — the system trust store is sufficient.
// This makes it the first entry in the list whose TLS does NOT rely on
// PINNED_ELECTRUMX_CERTS, and provides graceful fallback if every
// self-signed peer above is unreachable (e.g. corporate networks that
// strip unknown CAs but allow LE).
ElectrumxServer("electrum.nmc.ethicnology.com", 50002, useSsl = true, usePinnedTrustStore = false),
// Note: no bare-IP companion entry for electrum.nmc.ethicnology.com.
// Unlike the 46.229.238.187 / 23.158.233.10 peers above (which use
// usePinnedTrustStore=true and DER-SHA256 pinning that doesn't care
// about hostname verification), this server's TLS chains to a publicly-
// trusted Let's Encrypt cert whose SAN covers only electrum.nmc.ethicnology.com.
// Connecting by the bare IP 142.44.246.181 would fail standard hostname
// verification under the system trust manager, so the entry would never
// succeed in practice. The hostname entry above is the only useful form.
)
/** Tor-preferred server list: onion primary, clearnet fallback. */
@@ -135,4 +155,6 @@ val TOR_ELECTRUMX_SERVERS =
ElectrumxServer("relay.testls.bit", 50002, useSsl = true, usePinnedTrustStore = true),
// Bare IP peer (same operator/cert/box). See clearnet list above.
ElectrumxServer("23.158.233.10", 50002, useSsl = true, usePinnedTrustStore = true),
// electrum.nmc.ethicnology.com — public LE-cert ElectrumX. See clearnet list above.
ElectrumxServer("electrum.nmc.ethicnology.com", 50002, useSsl = true, usePinnedTrustStore = false),
)
@@ -56,8 +56,11 @@ class DimensionTag(
if (parts.size != 2) return null
return try {
val width = parts[0].toInt()
val height = parts[1].toInt()
// Some clients (e.g. Primal) emit floating-point dimensions like "317.0x498.0"
// in NIP-92 imeta tags. Parse as Double and truncate to keep those tags usable
// for pre-load layout reservation.
val width = parts[0].toDouble().toInt()
val height = parts[1].toDouble().toInt()
DimensionTag(width, height)
} catch (e: Exception) {
@@ -0,0 +1,75 @@
/*
* 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.nip94FileMetadata.tags
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class DimensionTagTest {
@Test
fun parsesIntegerDimensions() {
val tag = DimensionTag.parse("317x498")
assertNotNull(tag)
assertEquals(317, tag.width)
assertEquals(498, tag.height)
}
@Test
fun parsesFloatDimensionsFromPrimal() {
// Regression: kind:1 notes from Primal-style clients ship floating-point dims in
// their imeta tag (e.g. "dim 317.0x498.0"). Before this was tolerated the value
// parsed to null, the GIF/image container lost its aspectRatio modifier, and the
// post body collapsed to zero height until Coil delivered the bitmap.
val tag = DimensionTag.parse("317.0x498.0")
assertNotNull(tag)
assertEquals(317, tag.width)
assertEquals(498, tag.height)
}
@Test
fun truncatesNonIntegerFloats() {
val tag = DimensionTag.parse("317.9x498.4")
assertNotNull(tag)
assertEquals(317, tag.width)
assertEquals(498, tag.height)
}
@Test
fun rejectsZeroByZero() {
assertNull(DimensionTag.parse("0x0"))
}
@Test
fun rejectsMalformed() {
assertNull(DimensionTag.parse("not-a-dim"))
assertNull(DimensionTag.parse("317"))
assertNull(DimensionTag.parse("317x"))
}
@Test
fun aspectRatioMatchesPrimalGif() {
val tag = DimensionTag.parse("317.0x498.0")
assertNotNull(tag)
assertEquals(317f / 498f, tag.aspectRatio())
}
}
@@ -0,0 +1,126 @@
/*
* 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.nip01Core.relay.client.auth
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlin.test.Test
/**
* Reproduces issue #2946 `ClassCastException: LinkedHashMap$Entry cannot be
* cast to HashMap$TreeNode` thrown from
* `RelayAuthenticator$clientListener.onDisconnected`.
*
* OkHttp dispatches WebSocket callbacks on one thread per relay, so when many
* relays connect/disconnect simultaneously the listener's internal map is
* mutated concurrently. Once a bucket exceeds the HashMap TREEIFY_THRESHOLD (8)
* the concurrent treeification corrupts internal state.
*
* On the buggy code this test fails non-deterministically with a
* `ClassCastException` (or `ConcurrentModificationException` /
* `NullPointerException`). After the fix it must pass cleanly every run.
*/
class RelayAuthenticatorConcurrencyTest {
private class CapturingClient(
private val delegate: INostrClient = EmptyNostrClient(),
) : INostrClient by delegate {
@Volatile var captured: RelayConnectionListener? = null
override fun addConnectionListener(listener: RelayConnectionListener) {
captured = listener
}
}
private class FakeRelayClient(
override val url: NormalizedRelayUrl,
) : IRelayClient {
override fun connect() = Unit
override fun needsToReconnect() = false
override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit
override fun isConnected() = false
override fun sendOrConnectAndSync(cmd: Command) = Unit
override fun sendIfConnected(cmd: Command) = Unit
override fun disconnect() = Unit
}
@Test
fun concurrentConnectingAndDisconnecting_doesNotCorruptInternalState() {
runBlocking {
// The race only fires while the underlying HashMap is structurally
// growing — rehashing and bucket treeification. Once the map reaches
// its steady-state size, put/remove on existing keys touch a single
// node and won't reproduce. So drive many short "burst" cycles, each
// starting from an empty map and growing it past
// MIN_TREEIFY_CAPACITY (64) under concurrent load.
repeat(50) { burst ->
val client = CapturingClient()
val authenticator =
RelayAuthenticator(
client = client,
signWithAllLoggedInUsers = { emptyList() },
)
val listener =
client.captured
?: error("RelayAuthenticator did not register a listener")
val relays =
(0 until 256).map {
FakeRelayClient(NormalizedRelayUrl("wss://relay-$burst-$it.example/"))
}
withContext(Dispatchers.IO) {
(0 until 64)
.map { workerId ->
async {
// Each worker walks the relay set, connecting and
// disconnecting. Connects grow the map (rehash /
// treeify); disconnects shrink it; concurrent reads
// run alongside.
relays.forEachIndexed { idx, relay ->
if ((workerId + idx) and 1 == 0) {
listener.onConnecting(relay)
} else {
listener.onDisconnected(relay)
}
authenticator.hasFinishedAuthentication(relay.url)
}
}
}.awaitAll()
}
}
}
}
}