diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index d0c5b3397d..e61c08b852 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -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, 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(), + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a631b77d21..94825ee4f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt index 529f11ef78..789f6ff535 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt new file mode 100644 index 0000000000..c538def017 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt @@ -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 = + 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStore.kt index bfbaa0ed54..34fc03c772 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStore.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt index 6ac5b47415..d545ae7760 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt @@ -122,7 +122,7 @@ object ScheduledPostNotifier { } } - private fun ensureChannel(context: Context) { + fun ensureChannel(context: Context) { if (channel != null) return channel = NotificationChannel( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index a40e19f2f1..583dac2920 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 563cb0ce69..91ea5cfe64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 63a21a0212..62d165677a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/namecoin/NamecoinResolutionRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/namecoin/NamecoinResolutionRow.kt new file mode 100644 index 0000000000..e3bca2104d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/namecoin/NamecoinResolutionRow.kt @@ -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(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), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/LongPressCopyText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/LongPressCopyText.kt new file mode 100644 index 0000000000..1fa92b3b68 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/LongPressCopyText.kt @@ -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, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 04055ec777..10834421dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { ProfileUiSettingsScreen(accountViewModel, nav) } composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } + composableFromEnd { NotificationSettingsScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 3d5ece6229..b9e4ae495e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -239,6 +239,8 @@ sealed class Route { @Serializable object CallSettings : Route() + @Serializable object NotificationSettings : Route() + @Serializable object Lists : Route() @Serializable data class MyPeopleListView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index b00c1735a9..1d0483f4ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -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, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt index b64b8e0a2f..aef0d62e86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt @@ -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 }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index f650d11a6c..92b43cf4d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -514,6 +514,8 @@ class AccountViewModel( val canPreview: Boolean = true, val isHiddenAuthor: Boolean = false, val relevantReports: ImmutableSet = 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index cba0f06476..c11283c657 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -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): List = 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 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt index e53b7b54b1..9020fb4384 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt @@ -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, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt index 4a039d1b1e..2b2ac59469 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 11cf5d4356..f6d2fd7d88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -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), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 0ffcd44282..61cc4d5f16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index bf15f61607..7fb17f1bee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 9be0e54f6b..0d3a39e992 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -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)) - } - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt new file mode 100644 index 0000000000..b2914644d3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt @@ -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>(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()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 11faac4803..d3f9905105 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt index d190d18803..a188839456 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index 2dc59bc595..f0738c3956 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -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@.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, diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 648a37ebdd..9522c51b1f 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -359,6 +359,8 @@ Blokkeren Verwijderen Blokkeren + Discussie dempen + Discussie dempen opheffen Rapporteren Verwijderen Niet meer tonen @@ -634,6 +636,16 @@ Ontvolgen Dempen Dempen opheffen + Kan genegeerd worden door clients die dit commando niet ondersteunen. + Uit de ruimte kicken? + %1$s wordt verwijderd van het audiokanaal en uit de deelnemerslijst. Ze kunnen opnieuw deelnemen als ze de link hebben. + Kicken + Spreker forceren te dempen? + Vraagt %1$s’s client om de microfoon te dempen. Sommige clients negeren dit commando mogelijk. + Forceren dempen + Annuleren + Actie mislukt + De actie kon niet worden voorbereid. Ruimte delen Minimaliseren Minimaliseren om te blijven luisteren @@ -1033,6 +1045,11 @@ Kan gesprek niet starten Kan gesprek niet accepteren Aanmaken oproepsessie mislukt + Toestemming nodig + Amethyst heeft toegang tot de microfoon nodig om een spraakoproep te starten. Schakel dit in bij de app-instellingen. + Amethyst heeft toegang tot camera en microfoon nodig om een video-oproep te starten. Schakel dit in bij de app-instellingen. + Instellingen openen + Annuleren Gespreksinstellingen Spraak- en videogesprekken inschakelen Wanneer uitgeschakeld, worden belknoppen verborgen en worden inkomende gesprekken stil genegeerd. @@ -1054,6 +1071,10 @@ Verbinden met inbox-relays… Altijd-aan meldingsdienst 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. + Meldingen splitsen per gevolgden + Toon twee meldings-tabbladen — Volgend (mensen die je volgt) en Iedereen. De ongelezen-indicator licht alleen op voor activiteit van mensen die je volgt. + Volgend + Iedereen Batterij-optimalisatie actief Android kan relay-verbindingen op de achtergrond beperken. Schakel batterij-optimalisatie uit voor Amethyst voor betrouwbare meldingen. Nu oplossen @@ -1078,14 +1099,24 @@ Waarschuwen bij rapportages van volgers Spamfilter Verbergt identieke berichten van onbekenden die 5 keer of vaker voorkomen + Client-tag toevoegen aan mijn events + Wanneer ingeschakeld voegt Amethyst een NIP-89 client-tag toe aan events die je publiceert. Waarschuwen bij rapportages Toont waarschuwing wanneer een bericht 5 of meer rapportages van je volgers heeft + Drempel voor rapportage-waarschuwing + Toont waarschuwing wanneer berichten of profielen dit aantal rapportages van mensen die je volgt bereiken Gevoelige inhoud tonen Toont waarschuwing wanneer auteur inhoud als gevoelig heeft gemarkeerd Maximum hashtags per bericht Verbergt berichten met meer hashtags dan deze limiet. Stel in op 0 om uit te schakelen. Berichten verbergen die community-regels schenden 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. + Filtervoorkeuren + Geblokkeerde inhoud + + Je hebt nog geen gebruikers geblokkeerd. + Geen accounts zijn in deze sessie als spam gemarkeerd. + Geen verborgen woorden. Voeg hieronder een woord toe om berichten met dat woord te verbergen. Nieuw reactie-symbool Geen reactietypes ingesteld. Houd ingedrukt om te wijzigen. Zapraiser @@ -1263,6 +1294,7 @@ Spammers Gedempt. Tik voor geluid Geluid aan. Tik voor dempen + Demping opheffen %d seconden terug %d seconden vooruit Picture-in-Picture @@ -1282,6 +1314,8 @@ Alleen volgers van de locatie zien dit bericht. Alleen hashtag-exclusief bericht Alleen volgers van de hashtag zien dit bericht. + Reageer op een website + Reageer op een externe bron %1$d min lezen Locatie laden… Geen locatiemachtigingen @@ -1378,6 +1412,9 @@ Geen Blossom-app gevonden. Installeer een lokale Blossom-app om dit bestand te bekijken. Verborgen woorden Nieuwe woorden of zinnen verbergen + Gedempte discussies + Geen gedempte discussies + Onbekende discussie · %1$s Profielfoto Profielfoto\'s tonen Selecteer een optie @@ -1607,6 +1644,12 @@ Start-tabbladen Kies welke tabbladen op het startscherm verschijnen. Wanneer slechts één tab actief is, wordt de tabbalk verborgen. Alles + Profiel-weergave + Kies welke secties en feeds op gebruikersprofielschermen verschijnen. Standaard zijn alle opties ingeschakeld. + Profielbadges + App-aanbevelingen + Ontvangen zaps-feed + Volgers-feed Reactierij Configureer welke reactieknoppen worden getoond, hun volgorde en of tellers worden weergegeven. Ingeschakeld @@ -1641,6 +1684,8 @@ Video downloaden naar je apparaat (verborgen bij livestreams) Picture-in-Picture Video in zwevend venster afspelen (verborgen als niet ondersteund) + Casten naar apparaat + Video casten naar een Chromecast-ontvanger op je wifi (verborgen bij lokale bestanden) Profielfoto van %1$s Relay %1$s Relay-lijst uitvouwen @@ -1759,6 +1804,18 @@ Git-repository: %1$s Web: Klonen: + Openen + Samengevoegd + Gesloten + Concept + Overzicht + Problemen + Patches & PRs + Over + Links + Beheerders + Onderwerpen + Persoonlijke fork Statische website: %1$s Root-site Bron: @@ -1856,8 +1913,11 @@ Favoriete feed-algoritmes Feed-algoritmes die je hier hebt gesterd, verschijnen als filterchips op de startfeed. Open Ontdekken om meer toe te voegen. Pin je favoriete algoritmes + Tik op “%1$s” hieronder om algoritmes te bekijken. + Tik op het %1$s naast een feed om hem hier te bewaren. Feeds toevoegen + Meer toevoegen… %1$s vragen voor een feed… Je favoriete feed-algoritmes vragen voor feeds… Je feed verwerken… @@ -2250,6 +2310,10 @@ Afspelen Auto + Casten naar apparaat + Casten stoppen + Casten naar… + Zoeken naar apparaten op je wifi… HLS-upload Publiceer multi-resolutie HLS naar je mediaserver @@ -2448,6 +2512,9 @@ Gebruikt een on-device AI-model om tekstcorrecties en toonwijzigingen voor te stellen. Getrackte uitzendingen Gebruik de tracked broadcaster bij het verzenden van events. Toont live voortgang en per-relay-status tijdens uitzenden. + Opstel-instellingen + Automatisch concepten aanmaken + Slaat automatisch een concept op wanneer je typt of de opsteller verlaat met onverzonden tekst en stuurt dit naar je privé outbox-relays. Gebruik dit Sluiten Corrigeren diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d4dcea91c5..35d4afc9aa 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ Show Anyway This post was hidden because it mentions your hidden users or words Post was muted or reported by + This post has more than %1$d hashtags Event is loading or can\'t be found in your relay list 👀 Channel Image @@ -1541,6 +1542,7 @@ Copy Stack Copy to clipboard + Copied to clipboard Copy nprofile to clipboard Copy npub to clipboard Share or Save @@ -1674,9 +1676,18 @@ Read-only user No reactions setup + Notifications + Delivery + In-app display + Categories + Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there. + On + Silent + Off + Select a UnifiedPush App - Push Notification - From installed UnifiedPush apps + Push provider + Pick a UnifiedPush app to deliver notifications when Amethyst is closed. None Disables Push Notifications Uses app %1$s diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 5091181653..dc32830cc4 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -52,4 +52,6 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) { } @Composable -fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {} +fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {} + +fun hasPushNotificationProvider(): Boolean = false diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/namecoin/NamecoinResolutionRowTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/namecoin/NamecoinResolutionRowTest.kt new file mode 100644 index 0000000000..af91222256 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/namecoin/NamecoinResolutionRowTest.kt @@ -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 = "_", + ), + ), + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt index c00ed10519..29d750d34e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt @@ -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 `/.` 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 + // /., 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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt index 19301c0dc5..84dcf80c83 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt @@ -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 `` 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 /., 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/.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)) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 0ec8f6836e..61485c6e68 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -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")) } } diff --git a/desktopApp/compose-rules.pro b/desktopApp/compose-rules.pro index bb824643d1..8241422ed6 100644 --- a/desktopApp/compose-rules.pro +++ b/desktopApp/compose-rules.pro @@ -96,6 +96,16 @@ native ; } +# 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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt index d7f9c02f2f..58d7f9fa54 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt @@ -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(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 + } + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt index 18e08c306d..50ea042e58 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt @@ -55,6 +55,9 @@ object VlcjPlayerPool { private val idleThumbPlayers = ConcurrentLinkedQueue() 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() @@ -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 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index cce594855a..848b3f810f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -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) -> List, ) : IAuthStatus { - private val authStatus = mutableMapOf() + // 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() 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") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt index d98c77ac76..992b55328d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt @@ -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), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt index 098957f44e..0ec356b6d3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt @@ -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) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTagTest.kt new file mode 100644 index 0000000000..e97ecbf833 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTagTest.kt @@ -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()) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt new file mode 100644 index 0000000000..67fa00c2b7 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt @@ -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() + } + } + } + } +}