mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
Merge remote-tracking branch 'origin/main' into claude/fix-video-mime-type-sqkiz
This commit is contained in:
+25
-15
@@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.UiSettingsFlow
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -207,24 +207,34 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
|
||||
)
|
||||
}
|
||||
|
||||
fun hasPushNotificationProvider(): Boolean = true
|
||||
|
||||
@Composable
|
||||
fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {
|
||||
fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {
|
||||
val context = LocalContext.current
|
||||
|
||||
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
|
||||
SettingsRow(
|
||||
R.string.push_server_title,
|
||||
R.string.push_server_explainer,
|
||||
selectedItems = readableListWithExplainer,
|
||||
selectedIndex = list.indexOf(currentDistributor),
|
||||
) { index ->
|
||||
if (list[index] == "None") {
|
||||
sharedPrefs.dontAskForNotificationPermissions()
|
||||
sharedPrefs.dontShowPushNotificationSelector()
|
||||
PushDistributorHandler.forceRemoveDistributor(context)
|
||||
} else {
|
||||
PushDistributorHandler.saveDistributor(list[index])
|
||||
}
|
||||
val selectedIndex = list.indexOf(currentDistributor).coerceAtLeast(0)
|
||||
SettingsBlockTile(
|
||||
icon = MaterialSymbols.CloudSync,
|
||||
title = stringRes(R.string.push_server_title),
|
||||
description = stringRes(R.string.push_server_explainer),
|
||||
) {
|
||||
TextSpinner(
|
||||
label = null,
|
||||
placeholder = readableListWithExplainer[selectedIndex].title,
|
||||
options = readableListWithExplainer,
|
||||
onSelect = { index ->
|
||||
if (list[index] == "None") {
|
||||
sharedPrefs.dontAskForNotificationPermissions()
|
||||
sharedPrefs.dontShowPushNotificationSelector()
|
||||
PushDistributorHandler.forceRemoveDistributor(context)
|
||||
} else {
|
||||
PushDistributorHandler.saveDistributor(list[index])
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3067,8 +3067,10 @@ class Account(
|
||||
|
||||
fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors
|
||||
|
||||
private fun hasExcessiveHashtags(note: Note): Boolean {
|
||||
val limit = settings.syncedSettings.security.maxHashtagLimit.value
|
||||
fun maxHashtagLimit(): Int = settings.syncedSettings.security.maxHashtagLimit.value
|
||||
|
||||
fun hasExcessiveHashtags(note: Note): Boolean {
|
||||
val limit = maxHashtagLimit()
|
||||
return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Registry of user-facing notification channels and helpers to read their
|
||||
* current importance / open the system settings page for them.
|
||||
*
|
||||
* Android (post-Oreo) owns channel state — the app cannot toggle channel
|
||||
* importance directly. The Notifications settings screen surfaces the
|
||||
* channels here and routes the user to the system per-channel page.
|
||||
*
|
||||
* Foreground-service channels (relay-connection, nests audio) are
|
||||
* intentionally omitted: they're functional indicators, not content
|
||||
* notifications, and disabling them breaks the foreground service contract.
|
||||
*/
|
||||
object NotificationChannels {
|
||||
private const val TAG = "NotificationChannels"
|
||||
|
||||
enum class ChannelStatus { ON, SILENT, OFF }
|
||||
|
||||
/**
|
||||
* A single content-bearing notification channel exposed in the settings UI.
|
||||
* [ensure] creates the channel if missing — needed so the system per-channel
|
||||
* settings page has something to open even before the first notification fires.
|
||||
*/
|
||||
data class Entry(
|
||||
val nameRes: Int,
|
||||
val icon: MaterialSymbol,
|
||||
val channelId: (Context) -> String,
|
||||
val ensure: (Context) -> Unit,
|
||||
)
|
||||
|
||||
val contentChannels: List<Entry> =
|
||||
listOf(
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_dms_channel_name,
|
||||
icon = MaterialSymbols.Mail,
|
||||
channelId = { stringRes(it, R.string.app_notification_dms_channel_id) },
|
||||
ensure = { NotificationUtils.getOrCreateDMChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_mentions_channel_name,
|
||||
icon = MaterialSymbols.AlternateEmail,
|
||||
channelId = { stringRes(it, R.string.app_notification_mentions_channel_id) },
|
||||
ensure = { NotificationUtils.getOrCreateMentionChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_replies_channel_name,
|
||||
icon = MaterialSymbols.Chat,
|
||||
channelId = { stringRes(it, R.string.app_notification_replies_channel_id) },
|
||||
ensure = { NotificationUtils.getOrCreateReplyChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_reactions_channel_name,
|
||||
icon = MaterialSymbols.Favorite,
|
||||
channelId = { stringRes(it, R.string.app_notification_reactions_channel_id) },
|
||||
ensure = { NotificationUtils.getOrCreateReactionChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_zaps_channel_name,
|
||||
icon = MaterialSymbols.Bolt,
|
||||
channelId = { stringRes(it, R.string.app_notification_zaps_channel_id) },
|
||||
ensure = { NotificationUtils.getOrCreateZapChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_chess_channel_name,
|
||||
icon = MaterialSymbols.ChessKnight,
|
||||
channelId = { stringRes(it, R.string.app_notification_chess_channel_id) },
|
||||
ensure = { NotificationUtils.getOrCreateChessChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_scheduled_posts_channel_name,
|
||||
icon = MaterialSymbols.Schedule,
|
||||
channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) },
|
||||
ensure = { ScheduledPostNotifier.ensureChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_calls_channel_name,
|
||||
icon = MaterialSymbols.Call,
|
||||
channelId = { CallNotifier.CALL_CHANNEL_ID },
|
||||
ensure = { CallNotifier.getOrCreateCallChannel(it) },
|
||||
),
|
||||
)
|
||||
|
||||
fun statusOf(
|
||||
context: Context,
|
||||
channelId: String,
|
||||
): ChannelStatus {
|
||||
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return ChannelStatus.OFF
|
||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return ChannelStatus.OFF
|
||||
val channel = nm.getNotificationChannel(channelId) ?: return ChannelStatus.ON
|
||||
return when (channel.importance) {
|
||||
NotificationManager.IMPORTANCE_NONE -> ChannelStatus.OFF
|
||||
NotificationManager.IMPORTANCE_MIN, NotificationManager.IMPORTANCE_LOW -> ChannelStatus.SILENT
|
||||
else -> ChannelStatus.ON
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the system per-channel notification settings page. Falls back to
|
||||
* the app-level notification settings if the per-channel intent isn't
|
||||
* supported (e.g. the channel was never created, or on stripped-down ROMs).
|
||||
*/
|
||||
fun openChannelSettings(
|
||||
context: Context,
|
||||
channelId: String,
|
||||
) {
|
||||
try {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
putExtra(Settings.EXTRA_CHANNEL_ID, channelId)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Per-channel intent failed, falling back to app notification settings", e)
|
||||
openAppNotificationSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun openAppNotificationSettings(context: Context) {
|
||||
try {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to open app notification settings", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class SurgeDnsStore(
|
||||
readRecords(file)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG) { "Dropping corrupt DNS cache blob: ${t.message}" }
|
||||
file.delete()
|
||||
if (!file.delete()) Log.w(TAG) { "Failed to delete corrupt DNS cache blob at ${file.path}" }
|
||||
return
|
||||
}
|
||||
// restore() uses putIfAbsent and never marks dirty, so we deliberately do NOT clear the
|
||||
@@ -104,8 +104,8 @@ class SurgeDnsStore(
|
||||
file.parentFile?.mkdirs()
|
||||
writeRecords(tmp, records)
|
||||
if (!tmp.renameTo(file)) {
|
||||
file.delete()
|
||||
if (!tmp.renameTo(file)) {
|
||||
// If delete fails the second rename will fail too; skip straight to the copy fallback.
|
||||
if (!file.delete() || !tmp.renameTo(file)) {
|
||||
tmp.copyTo(file, overwrite = true)
|
||||
}
|
||||
}
|
||||
@@ -117,13 +117,13 @@ class SurgeDnsStore(
|
||||
} finally {
|
||||
// Cleans up after both happy paths (copyTo fallback) and failure paths (writeRecords
|
||||
// crashed partway, leaving a partial blob) so a corrupt tmp can't accumulate.
|
||||
if (tmp.exists()) tmp.delete()
|
||||
if (tmp.exists() && !tmp.delete()) Log.w(TAG) { "Failed to delete DNS cache tmp file at ${tmp.path}" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-clear the on-disk cache. Useful for diagnostics or when the user wipes data. */
|
||||
fun clear() {
|
||||
file.delete()
|
||||
if (file.exists() && !file.delete()) Log.w(TAG) { "Failed to clear DNS cache blob at ${file.path}" }
|
||||
}
|
||||
|
||||
private fun writeRecords(
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ object ScheduledPostNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
fun ensureChannel(context: Context) {
|
||||
if (channel != null) return
|
||||
channel =
|
||||
NotificationChannel(
|
||||
|
||||
@@ -27,7 +27,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
@@ -64,8 +63,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
open class EditPostViewModel : ViewModel() {
|
||||
@@ -186,81 +183,83 @@ open class EditPostViewModel : ViewModel() {
|
||||
context: Context,
|
||||
stripMetadata: Boolean = true,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel.launchSigner {
|
||||
val myAccount = account
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launchSigner
|
||||
|
||||
mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
|
||||
|
||||
val results =
|
||||
myMultiOrchestrator.upload(
|
||||
alt,
|
||||
if (sensitiveContent) "" else null,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
useH265Codec,
|
||||
stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
try {
|
||||
val results =
|
||||
myMultiOrchestrator.upload(
|
||||
alt,
|
||||
if (sensitiveContent) "" else null,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
useH265Codec,
|
||||
stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
val urls =
|
||||
results.successful.mapNotNull { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 =
|
||||
myAccount.createNip95(
|
||||
byteArray = state.result.bytes,
|
||||
headerInfo = state.result.fileHeader,
|
||||
alt = alt,
|
||||
contentWarningReason = if (sensitiveContent) "" else null,
|
||||
)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
if (results.allGood) {
|
||||
val urls =
|
||||
results.successful.mapNotNull { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 =
|
||||
myAccount.createNip95(
|
||||
byteArray = state.result.bytes,
|
||||
headerInfo = state.result.fileHeader,
|
||||
alt = alt,
|
||||
contentWarningReason = if (sensitiveContent) "" else null,
|
||||
)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
"nostr:" + it.toNEvent()
|
||||
note?.let {
|
||||
"nostr:" + it.toNEvent()
|
||||
}
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.fileHeader.thumbHash
|
||||
?.let { thumbhash(it.thumbhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
if (sensitiveContent) sensitiveContent("")
|
||||
}.build()
|
||||
|
||||
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
|
||||
|
||||
state.result.url
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.fileHeader.thumbHash
|
||||
?.let { thumbhash(it.thumbhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
if (sensitiveContent) sensitiveContent("")
|
||||
}.build()
|
||||
|
||||
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
|
||||
|
||||
state.result.url
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
message = message.insertUrlAtCursor(urls.joinToString(" "))
|
||||
urlPreview = findUrlInMessage()
|
||||
message = message.insertUrlAtCursor(urls.joinToString(" "))
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
this@EditPostViewModel.multiOrchestrator = null
|
||||
} else {
|
||||
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
|
||||
this@EditPostViewModel.multiOrchestrator = null
|
||||
} else {
|
||||
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
|
||||
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
}
|
||||
} finally {
|
||||
mediaUploadTracker.finishUpload()
|
||||
}
|
||||
|
||||
mediaUploadTracker.finishUpload()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -90,10 +91,11 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
fun upload(
|
||||
context: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
onSucess: () -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) = try {
|
||||
uploadUnsafe(context, onSucess, onError)
|
||||
uploadUnsafe(context, accountViewModel, onSucess, onError)
|
||||
} catch (e: SignerExceptions.ReadOnlyException) {
|
||||
onError(
|
||||
stringRes(context, R.string.read_only_user),
|
||||
@@ -103,6 +105,7 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
fun uploadUnsafe(
|
||||
context: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
onSucess: () -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
@@ -155,10 +158,13 @@ open class NewMediaModel : ViewModel() {
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
// Sign + publish via launchSigner so SignerExceptions surface
|
||||
// through the standard toastManager pipeline (and timed-out /
|
||||
// rejected prompts get logged instead of crashing the process).
|
||||
val nip95jobs =
|
||||
nip95s.map {
|
||||
// upload each file as an individual nip95 event.
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel.launchSigner {
|
||||
val nip95 = myAccount.createNip95(it.bytes, headerInfo = it.fileHeader, caption, if (sensitiveContent) "" else null)
|
||||
myAccount.consumeAndSendNip95(nip95.first, nip95.second)
|
||||
}
|
||||
@@ -166,9 +172,8 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
val videoJobs =
|
||||
videosAndOthers.map {
|
||||
// upload each file as an individual nip95 event.
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account?.sendHeader(
|
||||
accountViewModel.launchSigner {
|
||||
myAccount.sendHeader(
|
||||
url = it.url,
|
||||
magnetUri = it.magnet,
|
||||
headerInfo = it.fileHeader,
|
||||
@@ -182,8 +187,8 @@ open class NewMediaModel : ViewModel() {
|
||||
val imageJobs =
|
||||
if (imageUrls.isNotEmpty()) {
|
||||
listOf(
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account?.sendAllAsOnePictureEvent(
|
||||
accountViewModel.launchSigner {
|
||||
myAccount.sendAllAsOnePictureEvent(
|
||||
urlHeaderInfo = imageUrls,
|
||||
caption = caption,
|
||||
contentWarningReason = if (sensitiveContent) "" else null,
|
||||
|
||||
@@ -110,7 +110,7 @@ fun NewMediaView(
|
||||
onClose()
|
||||
},
|
||||
onPost = {
|
||||
postViewModel.upload(context, onClose, accountViewModel.toastManager::toast)
|
||||
postViewModel.upload(context, accountViewModel, onClose, accountViewModel.toastManager::toast)
|
||||
postViewModel.selectedServer?.let {
|
||||
account.settings.changeDefaultFileServer(it)
|
||||
}
|
||||
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components.namecoin
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Translate a [NamecoinResolveOutcome] from the quartz resolver into the
|
||||
* shared [NamecoinResolveState] used elsewhere in the app (e.g. the
|
||||
* desktop `SearchScreen`'s inline Namecoin lookup row).
|
||||
*
|
||||
* Mirrors the wording desktop already ships so the same identifier
|
||||
* produces the same diagnostic string regardless of which surface
|
||||
* triggered the lookup. Callers are expected to handle
|
||||
* [NamecoinResolveOutcome.Success] separately (it needs a [User]
|
||||
* lookup through [com.vitorpamplona.amethyst.model.LocalCache], which
|
||||
* this helper has no access to).
|
||||
*/
|
||||
fun mapOutcomeToResolveState(outcome: NamecoinResolveOutcome): NamecoinResolveState =
|
||||
when (outcome) {
|
||||
is NamecoinResolveOutcome.Success ->
|
||||
// Success is intentionally NOT handled here — callers must
|
||||
// resolve the pubkey through LocalCache first.
|
||||
error("mapOutcomeToResolveState called with Success outcome; resolve via LocalCache instead")
|
||||
|
||||
is NamecoinResolveOutcome.NameNotFound -> NamecoinResolveState.NotFound
|
||||
|
||||
is NamecoinResolveOutcome.NoNostrField ->
|
||||
NamecoinResolveState.Error("${outcome.name} is registered but has no Nostr pubkey")
|
||||
|
||||
is NamecoinResolveOutcome.MalformedRecord ->
|
||||
// Surface the parser detail verbatim so the publisher of the
|
||||
// broken record can locate the bad byte
|
||||
// (kotlinx.serialization includes a column number).
|
||||
NamecoinResolveState.Error("${outcome.name} record is malformed: ${outcome.error}")
|
||||
|
||||
is NamecoinResolveOutcome.ServersUnreachable ->
|
||||
NamecoinResolveState.Error("ElectrumX servers unreachable — check your connection or try again")
|
||||
|
||||
is NamecoinResolveOutcome.InvalidIdentifier ->
|
||||
NamecoinResolveState.Error("Invalid Namecoin identifier")
|
||||
|
||||
NamecoinResolveOutcome.Timeout ->
|
||||
NamecoinResolveState.Error("Resolution timed out — servers may be slow, try again")
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight syntactic check: does this look like something we should
|
||||
* route to Namecoin? Mirrors [com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver.isNamecoinIdentifier]
|
||||
* but tolerates a leading `@` (matches the dropdown's [com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState.userSearchTermOrNull]).
|
||||
*/
|
||||
fun looksLikeNamecoinIdentifier(raw: String): Boolean {
|
||||
val trimmed = raw.trim().removePrefix("@").lowercase()
|
||||
if (trimmed.length < 5) return false
|
||||
return trimmed.endsWith(".bit") ||
|
||||
trimmed.contains("@") && trimmed.substringAfter('@').endsWith(".bit")
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Namecoin resolution indicator + result row. Designed to be
|
||||
* mounted alongside any text input whose local-cache prefix search can
|
||||
* race ahead of an on-chain `.bit` lookup (the onchain-zap recipient
|
||||
* field and the global search bar both have this race).
|
||||
*
|
||||
* Behaviour:
|
||||
* - Renders nothing when [searchInput] is not a `.bit` identifier.
|
||||
* - Shows a small spinner row ("Resolving on Namecoin…") while the
|
||||
* ElectrumX lookup is in flight (after a 300 ms debounce to match
|
||||
* typical input-field debounce intervals).
|
||||
* - On success, shows the resolved user as a tappable row with a
|
||||
* `MaterialSymbols.Link` badge labelled "Namecoin"; tapping calls
|
||||
* [onUserResolved].
|
||||
* - On failure, shows a single explanatory line in the error colour.
|
||||
*
|
||||
* State is held in [NamecoinResolveState] (the same sealed class the
|
||||
* desktop `SearchScreen` and `NamecoinNameService` already use) so this
|
||||
* row stays in lockstep with the rest of the app's Namecoin UI.
|
||||
*
|
||||
* The composable is intentionally self-contained: it owns its own
|
||||
* [LaunchedEffect] keyed on [searchInput], so it cancels in-flight
|
||||
* lookups whenever the user keeps typing.
|
||||
*
|
||||
* @param modifier applied to the outer `Column` so callers can position
|
||||
* or pad the row (e.g. the search bar pads horizontally).
|
||||
*/
|
||||
@Composable
|
||||
fun NamecoinResolutionRow(
|
||||
searchInput: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
onUserResolved: (User) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val trimmed = remember(searchInput) { searchInput.trim().removePrefix("@") }
|
||||
if (!looksLikeNamecoinIdentifier(trimmed)) return
|
||||
|
||||
var state by remember { mutableStateOf<NamecoinResolveState?>(null) }
|
||||
|
||||
LaunchedEffect(trimmed) {
|
||||
// Match UserSuggestionState's 300 ms debounce so we don't fire a
|
||||
// lookup on every keystroke.
|
||||
delay(300)
|
||||
state = NamecoinResolveState.Loading
|
||||
val outcome =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Amethyst.instance.namecoinResolver.resolveDetailed(trimmed)
|
||||
}.getOrElse {
|
||||
NamecoinResolveOutcome.ServersUnreachable(
|
||||
it.message ?: it::class.simpleName ?: "Lookup error",
|
||||
)
|
||||
}
|
||||
}
|
||||
state =
|
||||
when (outcome) {
|
||||
is NamecoinResolveOutcome.Success -> NamecoinResolveState.Resolved(outcome.result)
|
||||
else -> mapOutcomeToResolveState(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
when (val s = state) {
|
||||
null, NamecoinResolveState.Loading -> ResolvingChip(trimmed)
|
||||
is NamecoinResolveState.Resolved -> ResolvedRow(trimmed, s, accountViewModel, onUserResolved)
|
||||
NamecoinResolveState.NotFound -> FailedRow("No record for $trimmed on Namecoin.")
|
||||
is NamecoinResolveState.Error -> FailedRow(s.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResolvingChip(query: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Resolving $query on Namecoin…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResolvedRow(
|
||||
query: String,
|
||||
state: NamecoinResolveState.Resolved,
|
||||
accountViewModel: AccountViewModel,
|
||||
onUserResolved: (User) -> Unit,
|
||||
) {
|
||||
// Look up the User in the same cache the rest of the app uses, exactly
|
||||
// the way desktop's SearchScreen does. Falls back to a malformed-record
|
||||
// error row if the pubkey somehow fails the hex shape check.
|
||||
val user =
|
||||
remember(state.result.pubkey) {
|
||||
accountViewModel.account.cache.checkGetOrCreateUser(state.result.pubkey)
|
||||
}
|
||||
if (user == null) {
|
||||
FailedRow(
|
||||
"${state.result.namecoinName} record is malformed: " +
|
||||
"pubkey ${state.result.pubkey} is not a valid hex key",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onUserResolved(user) },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
UserPicture(
|
||||
userHex = user.pubkeyHex,
|
||||
size = 32.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = user.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = query,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
NamecoinBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NamecoinBadge() {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Link,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Namecoin",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailedRow(message: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.errorContainer),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components.util
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Text composable that opens [onClick] on a tap and copies [copyValue] to the
|
||||
* system clipboard on a long-press (with a Toast confirmation).
|
||||
*
|
||||
* This is the long-press-to-copy counterpart of [com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary].
|
||||
* It deliberately uses a plain [Text] + [combinedClickable] outer modifier
|
||||
* rather than an inline `LinkAnnotation.Clickable` inside an `AnnotatedString`,
|
||||
* because annotation-level clicks can't see a parent [combinedClickable]'s
|
||||
* long-press: the parent's tap area sits above the annotation's hit-test
|
||||
* region and would consume the tap before the annotation could fire it.
|
||||
*
|
||||
* @param displayText text shown to the user (may be a stripped form, e.g.
|
||||
* "example.com" for a website value "https://example.com").
|
||||
* @param copyValue raw value placed on the clipboard on long-press
|
||||
* (typically the full, unmodified profile field).
|
||||
* @param onClick tap action — usually "open the link" or "expand zap UI".
|
||||
* @param toastResId string resource shown via [Toast.LENGTH_SHORT] after the
|
||||
* value is placed on the clipboard. Defaults to a generic
|
||||
* "Copied to clipboard" message.
|
||||
* @param onLongClickLabelResId accessibility label exposed to TalkBack for
|
||||
* the long-press action. Defaults to "Copy to clipboard".
|
||||
*/
|
||||
@Composable
|
||||
fun LongPressCopyText(
|
||||
displayText: String,
|
||||
copyValue: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MaterialTheme.colorScheme.primary,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
softWrap: Boolean = true,
|
||||
overflow: TextOverflow = TextOverflow.Ellipsis,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
toastResId: Int = R.string.copied_to_clipboard,
|
||||
onLongClickLabelResId: Int = R.string.copy_to_clipboard,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val longClickLabel = stringRes(onLongClickLabelResId)
|
||||
|
||||
Text(
|
||||
text = displayText,
|
||||
color = color,
|
||||
style = style,
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
maxLines = maxLines,
|
||||
modifier =
|
||||
modifier.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
clipboard.setText(copyValue)
|
||||
Toast.makeText(context, stringRes(context, toastResId), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onLongClickLabel = longClickLabel,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -164,6 +164,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsSc
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MutedThreadsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen
|
||||
@@ -326,6 +327,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.NotificationSettings> { NotificationSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ImportFollowsPickFollows> {
|
||||
ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
|
||||
|
||||
@@ -239,6 +239,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object CallSettings : Route()
|
||||
|
||||
@Serializable object NotificationSettings : Route()
|
||||
|
||||
@Serializable object Lists : Route()
|
||||
|
||||
@Serializable data class MyPeopleListView(
|
||||
|
||||
@@ -107,6 +107,23 @@ fun HiddenNotePreview() {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun HiddenNoteExcessiveHashtagsPreview() {
|
||||
ThemeComparisonColumn(
|
||||
toPreview = {
|
||||
HiddenNote(
|
||||
reports = persistentSetOf(),
|
||||
isHiddenAuthor = false,
|
||||
hasExcessiveHashtags = true,
|
||||
hashtagLimit = 8,
|
||||
accountViewModel = mockAccountViewModel(),
|
||||
nav = EmptyNav(),
|
||||
) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun HiddenNote(
|
||||
@@ -115,8 +132,11 @@ fun HiddenNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
nav: INav,
|
||||
hasExcessiveHashtags: Boolean = false,
|
||||
hashtagLimit: Int = 0,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val hasReporters = isHiddenAuthor || reports.isNotEmpty()
|
||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 20.dp),
|
||||
@@ -127,26 +147,42 @@ fun HiddenNote(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(30.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.post_was_flagged_as_inappropriate_by),
|
||||
color = Color.Gray,
|
||||
)
|
||||
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
|
||||
if (isHiddenAuthor) {
|
||||
UserPicture(
|
||||
user = accountViewModel.userProfile(),
|
||||
size = Size35dp,
|
||||
nav = nav,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
reports.forEach {
|
||||
NoteAuthorPicture(
|
||||
baseNote = it,
|
||||
size = Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
if (hasExcessiveHashtags) {
|
||||
Text(
|
||||
text = stringRes(R.string.post_was_hidden_due_to_too_many_hashtags, hashtagLimit),
|
||||
color = Color.Gray,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
if (hasReporters || !hasExcessiveHashtags) {
|
||||
Text(
|
||||
text = stringRes(R.string.post_was_flagged_as_inappropriate_by),
|
||||
color = Color.Gray,
|
||||
modifier =
|
||||
if (hasExcessiveHashtags) {
|
||||
Modifier.padding(top = 10.dp)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
|
||||
if (isHiddenAuthor) {
|
||||
UserPicture(
|
||||
user = accountViewModel.userProfile(),
|
||||
size = Size35dp,
|
||||
nav = nav,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
reports.forEach {
|
||||
NoteAuthorPicture(
|
||||
baseNote = it,
|
||||
size = Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,11 +77,13 @@ fun WatchBlockAndReport(
|
||||
normalNote(isHidden.canPreview)
|
||||
} else {
|
||||
HiddenNote(
|
||||
isHidden.relevantReports,
|
||||
isHidden.isHiddenAuthor,
|
||||
accountViewModel,
|
||||
modifier,
|
||||
nav,
|
||||
reports = isHidden.relevantReports,
|
||||
isHiddenAuthor = isHidden.isHiddenAuthor,
|
||||
hasExcessiveHashtags = isHidden.hasExcessiveHashtags,
|
||||
hashtagLimit = isHidden.hashtagLimit,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = modifier,
|
||||
nav = nav,
|
||||
onClick = { showAnyway.value = true },
|
||||
)
|
||||
}
|
||||
|
||||
+12
-7
@@ -514,6 +514,8 @@ class AccountViewModel(
|
||||
val canPreview: Boolean = true,
|
||||
val isHiddenAuthor: Boolean = false,
|
||||
val relevantReports: ImmutableSet<Note> = persistentSetOf(),
|
||||
val hasExcessiveHashtags: Boolean = false,
|
||||
val hashtagLimit: Int = 0,
|
||||
)
|
||||
|
||||
fun isNoteAcceptable(
|
||||
@@ -546,12 +548,16 @@ class AccountViewModel(
|
||||
// No need to process reports if nothing is wrong
|
||||
NoteComposeReportState(isPostHidden, isAcceptable = true, canPreview = true, isHiddenAuthor = false)
|
||||
} else {
|
||||
val hashtagLimit = account.maxHashtagLimit()
|
||||
val hasExcessiveHashtags = account.hasExcessiveHashtags(note)
|
||||
NoteComposeReportState(
|
||||
isPostHidden,
|
||||
newIsAcceptable,
|
||||
newCanPreview,
|
||||
false,
|
||||
account.getRelevantReports(note).toImmutableSet(),
|
||||
isPostHidden = isPostHidden,
|
||||
isAcceptable = newIsAcceptable,
|
||||
canPreview = newCanPreview,
|
||||
isHiddenAuthor = false,
|
||||
relevantReports = account.getRelevantReports(note).toImmutableSet(),
|
||||
hasExcessiveHashtags = hasExcessiveHashtags,
|
||||
hashtagLimit = hashtagLimit,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1061,7 +1067,7 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
inline fun launchSigner(crossinline action: suspend () -> Unit) {
|
||||
inline fun launchSigner(crossinline action: suspend () -> Unit) =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
action()
|
||||
@@ -1100,7 +1106,6 @@ class AccountViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun approveCommunityPost(
|
||||
post: Note,
|
||||
|
||||
+17
-1
@@ -30,6 +30,8 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
|
||||
class ChatroomListKnownFeedFilter(
|
||||
@@ -111,7 +113,7 @@ class ChatroomListKnownFeedFilter(
|
||||
newRelevantPublicMessages.forEach { newNotePair ->
|
||||
var hasUpdated = false
|
||||
oldList.forEach { oldNote ->
|
||||
val channelId = (oldNote.event as? ChannelMessageEvent)?.channelId()
|
||||
val channelId = publicChannelIdOf(oldNote)
|
||||
if (newNotePair.key == channelId) {
|
||||
hasUpdated = true
|
||||
if ((newNotePair.value.createdAt() ?: 0L) > (oldNote.createdAt() ?: 0L)) {
|
||||
@@ -260,4 +262,18 @@ class ChatroomListKnownFeedFilter(
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
|
||||
// Maps a note that represents a public chat row to its channel id. The
|
||||
// representative note for a channel may be the channel's create event
|
||||
// (id == channelId), a metadata update, or a message — match all three so
|
||||
// an arriving ChannelMessageEvent replaces an existing placeholder
|
||||
// metadata/create note for the same channel instead of duplicating it
|
||||
// (which would yield the same LazyColumn key twice).
|
||||
private fun publicChannelIdOf(note: Note): String? =
|
||||
when (val event = note.event) {
|
||||
is ChannelMessageEvent -> event.channelId()
|
||||
is ChannelMetadataEvent -> event.channelId()
|
||||
is ChannelCreateEvent -> event.id
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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,
|
||||
) {
|
||||
|
||||
+6
-3
@@ -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),
|
||||
|
||||
+21
-20
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+19
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+2
-104
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationChannels
|
||||
import com.vitorpamplona.amethyst.ui.components.PushNotificationProviderTile
|
||||
import com.vitorpamplona.amethyst.ui.components.hasPushNotificationProvider
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
|
||||
@Composable
|
||||
fun NotificationSettingsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(id = R.string.notification_settings), nav) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
DeliverySection(accountViewModel)
|
||||
DisplaySection(accountViewModel)
|
||||
CategoriesSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeliverySection(accountViewModel: AccountViewModel) {
|
||||
val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
SettingsSection(R.string.notification_settings_section_delivery) {
|
||||
if (hasPushNotificationProvider()) {
|
||||
PushNotificationProviderTile(accountViewModel.settings.uiSettingsFlow)
|
||||
SettingsDivider()
|
||||
}
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Notifications,
|
||||
title = R.string.always_on_notif_setting_title,
|
||||
description = R.string.always_on_notif_setting_description,
|
||||
checked = alwaysOn,
|
||||
onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() },
|
||||
)
|
||||
}
|
||||
|
||||
if (alwaysOn) {
|
||||
BatteryOptimizationBanner()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplaySection(accountViewModel: AccountViewModel) {
|
||||
val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
SettingsSection(R.string.notification_settings_section_display) {
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Forum,
|
||||
title = R.string.split_notifications_setting_title,
|
||||
description = R.string.split_notifications_setting_description,
|
||||
checked = splitByFollows,
|
||||
onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoriesSection() {
|
||||
val context = LocalContext.current
|
||||
val entries = NotificationChannels.contentChannels
|
||||
|
||||
// Read each channel's importance after every resume so toggling
|
||||
// sound/importance in the system page reflects back here. The map IS
|
||||
// the state — no key-bump trick needed.
|
||||
var statuses by remember {
|
||||
mutableStateOf<Map<String, NotificationChannels.ChannelStatus>>(emptyMap())
|
||||
}
|
||||
LifecycleResumeEffect(Unit) {
|
||||
statuses =
|
||||
entries.associate {
|
||||
val id = it.channelId(context)
|
||||
id to NotificationChannels.statusOf(context, id)
|
||||
}
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
SettingsSection(R.string.notification_settings_section_categories) {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
if (index > 0) SettingsDivider()
|
||||
val channelId = remember(entry) { entry.channelId(context) }
|
||||
// Default to ON for channels not yet created — matches Android's
|
||||
// own default importance, so the badge isn't misleading before the
|
||||
// user has interacted with the channel.
|
||||
val status = statuses[channelId] ?: NotificationChannels.ChannelStatus.ON
|
||||
SettingsItem(
|
||||
title = entry.nameRes,
|
||||
icon = entry.icon,
|
||||
trailing = { ChannelStatusBadge(status) },
|
||||
onClick = {
|
||||
// Lazy-create the channel right before opening so the system
|
||||
// per-channel page has something to display; idempotent.
|
||||
entry.ensure(context)
|
||||
NotificationChannels.openChannelSettings(context, channelId)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.notification_settings_categories_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelStatusBadge(status: NotificationChannels.ChannelStatus) {
|
||||
when (status) {
|
||||
NotificationChannels.ChannelStatus.ON ->
|
||||
StatusChip(
|
||||
label = stringRes(R.string.notification_channel_status_on),
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
NotificationChannels.ChannelStatus.SILENT ->
|
||||
StatusChip(
|
||||
label = stringRes(R.string.notification_channel_status_silent),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
NotificationChannels.ChannelStatus.OFF ->
|
||||
StatusChip(
|
||||
label = stringRes(R.string.notification_channel_status_off),
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusChip(
|
||||
label: String,
|
||||
containerColor: Color,
|
||||
contentColor: Color,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(containerColor)
|
||||
.padding(horizontal = 10.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BatteryOptimizationBanner() {
|
||||
val context = LocalContext.current
|
||||
var isExempt by remember {
|
||||
mutableStateOf(BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context))
|
||||
}
|
||||
LifecycleResumeEffect(Unit) {
|
||||
isExempt = BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context)
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
if (isExempt) return
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.battery_optimization_title),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.battery_optimization_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Button(
|
||||
onClick = { BatteryOptimizationHelper.requestBatteryOptimizationExemption(context) },
|
||||
) {
|
||||
Text(stringRes(R.string.battery_optimization_fix_now))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NotificationSettingsScreenPreview() {
|
||||
ThemeComparisonColumn {
|
||||
NotificationSettingsScreen(mockAccountViewModel(), EmptyNav())
|
||||
}
|
||||
}
|
||||
+3
-24
@@ -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,
|
||||
|
||||
+20
@@ -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
|
||||
|
||||
+21
@@ -63,11 +63,13 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
@@ -158,10 +160,17 @@ fun OnchainZapSendDialog(
|
||||
accountViewModel.zapAmountChoices()
|
||||
}
|
||||
|
||||
// Mirror the dropdown's NIP-05 / Namecoin (.bit) resolution so Send can
|
||||
// enable as soon as the typed name resolves, without forcing the user to
|
||||
// tap the suggestion. Reuses the exact same path as the dropdown, so
|
||||
// bare .bit names (e.g. testls.bit) and m@testls.bit both work.
|
||||
val nip05Resolved by userSuggestions.nip05ResolutionFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
val resolvedRecipient: HexKey? =
|
||||
recipientPubKey
|
||||
?: selectedUser?.pubkeyHex
|
||||
?: searchInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) }
|
||||
?: nip05Resolved?.pubkeyHex
|
||||
val amountSats = amountInput.trim().toLongOrNull()
|
||||
val canSend =
|
||||
!sending &&
|
||||
@@ -406,6 +415,18 @@ private fun RecipientSection(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// Inline Namecoin lookup feedback. Local-cache suggestions can race
|
||||
// ahead of the on-chain resolution (especially when the user has
|
||||
// resolved a sibling `user@<host>.bit` earlier in the session and the
|
||||
// current query is the bare host), so we surface the in-flight state +
|
||||
// the eventual on-chain match in its own row, distinct from the
|
||||
// generic dropdown. Failures are surfaced here too.
|
||||
NamecoinResolutionRow(
|
||||
searchInput = searchInput,
|
||||
accountViewModel = accountViewModel,
|
||||
onUserResolved = onSelectUser,
|
||||
)
|
||||
|
||||
if (searchInput.length > 2) {
|
||||
ShowUserSuggestionList(
|
||||
userSuggestions = userSuggestions,
|
||||
|
||||
@@ -359,6 +359,8 @@
|
||||
<string name="quick_action_block_dialog_btn">Blokkeren</string>
|
||||
<string name="quick_action_delete_dialog_btn">Verwijderen</string>
|
||||
<string name="quick_action_block">Blokkeren</string>
|
||||
<string name="quick_action_mute_thread">Discussie dempen</string>
|
||||
<string name="quick_action_unmute_thread">Discussie dempen opheffen</string>
|
||||
<string name="quick_action_report">Rapporteren</string>
|
||||
<string name="quick_action_delete_button">Verwijderen</string>
|
||||
<string name="quick_action_dont_show_again_button">Niet meer tonen</string>
|
||||
@@ -634,6 +636,16 @@
|
||||
<string name="nest_participant_unfollow">Ontvolgen</string>
|
||||
<string name="nest_participant_mute">Dempen</string>
|
||||
<string name="nest_participant_unmute">Dempen opheffen</string>
|
||||
<string name="nest_force_mute_note">Kan genegeerd worden door clients die dit commando niet ondersteunen.</string>
|
||||
<string name="nest_confirm_kick_title">Uit de ruimte kicken?</string>
|
||||
<string name="nest_confirm_kick_body">%1$s wordt verwijderd van het audiokanaal en uit de deelnemerslijst. Ze kunnen opnieuw deelnemen als ze de link hebben.</string>
|
||||
<string name="nest_confirm_kick_confirm">Kicken</string>
|
||||
<string name="nest_confirm_force_mute_title">Spreker forceren te dempen?</string>
|
||||
<string name="nest_confirm_force_mute_body">Vraagt %1$s’s client om de microfoon te dempen. Sommige clients negeren dit commando mogelijk.</string>
|
||||
<string name="nest_confirm_force_mute_confirm">Forceren dempen</string>
|
||||
<string name="nest_confirm_cancel">Annuleren</string>
|
||||
<string name="nest_toast_host_action_failed_title">Actie mislukt</string>
|
||||
<string name="nest_toast_host_action_failed_template_null">De actie kon niet worden voorbereid.</string>
|
||||
<string name="nest_share_action">Ruimte delen</string>
|
||||
<string name="nest_minimize">Minimaliseren</string>
|
||||
<string name="nest_minimize_description">Minimaliseren om te blijven luisteren</string>
|
||||
@@ -1033,6 +1045,11 @@
|
||||
<string name="call_failed_start">Kan gesprek niet starten</string>
|
||||
<string name="call_failed_accept">Kan gesprek niet accepteren</string>
|
||||
<string name="call_failed_session">Aanmaken oproepsessie mislukt</string>
|
||||
<string name="call_permission_denied_title">Toestemming nodig</string>
|
||||
<string name="call_permission_denied_voice">Amethyst heeft toegang tot de microfoon nodig om een spraakoproep te starten. Schakel dit in bij de app-instellingen.</string>
|
||||
<string name="call_permission_denied_video">Amethyst heeft toegang tot camera en microfoon nodig om een video-oproep te starten. Schakel dit in bij de app-instellingen.</string>
|
||||
<string name="call_permission_denied_open_settings">Instellingen openen</string>
|
||||
<string name="call_permission_denied_cancel">Annuleren</string>
|
||||
<string name="call_settings">Gespreksinstellingen</string>
|
||||
<string name="call_settings_enable_calls">Spraak- en videogesprekken inschakelen</string>
|
||||
<string name="call_settings_enable_calls_description">Wanneer uitgeschakeld, worden belknoppen verborgen en worden inkomende gesprekken stil genegeerd.</string>
|
||||
@@ -1054,6 +1071,10 @@
|
||||
<string name="always_on_notif_connecting">Verbinden met inbox-relays…</string>
|
||||
<string name="always_on_notif_setting_title">Altijd-aan meldingsdienst</string>
|
||||
<string name="always_on_notif_setting_description">Houdt een persistente verbinding met je inbox-relays voor directe melding. Toont een permanente notificatie. Gebruikt meer batterij maar zorgt dat je nooit een bericht mist.</string>
|
||||
<string name="split_notifications_setting_title">Meldingen splitsen per gevolgden</string>
|
||||
<string name="split_notifications_setting_description">Toon twee meldings-tabbladen — Volgend (mensen die je volgt) en Iedereen. De ongelezen-indicator licht alleen op voor activiteit van mensen die je volgt.</string>
|
||||
<string name="notification_tab_following">Volgend</string>
|
||||
<string name="notification_tab_everyone">Iedereen</string>
|
||||
<string name="battery_optimization_title">Batterij-optimalisatie actief</string>
|
||||
<string name="battery_optimization_description">Android kan relay-verbindingen op de achtergrond beperken. Schakel batterij-optimalisatie uit voor Amethyst voor betrouwbare meldingen.</string>
|
||||
<string name="battery_optimization_fix_now">Nu oplossen</string>
|
||||
@@ -1078,14 +1099,24 @@
|
||||
<string name="warn_when_posts_have_reports_from_your_follows">Waarschuwen bij rapportages van volgers</string>
|
||||
<string name="filter_spam_from_strangers_title">Spamfilter</string>
|
||||
<string name="filter_spam_from_strangers_explainer">Verbergt identieke berichten van onbekenden die 5 keer of vaker voorkomen</string>
|
||||
<string name="add_client_tag_title">Client-tag toevoegen aan mijn events</string>
|
||||
<string name="add_client_tag_explainer">Wanneer ingeschakeld voegt Amethyst een NIP-89 client-tag toe aan events die je publiceert.</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows_title">Waarschuwen bij rapportages</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Toont waarschuwing wanneer een bericht 5 of meer rapportages van je volgers heeft</string>
|
||||
<string name="report_warning_threshold_title">Drempel voor rapportage-waarschuwing</string>
|
||||
<string name="report_warning_threshold_explainer">Toont waarschuwing wanneer berichten of profielen dit aantal rapportages van mensen die je volgt bereiken</string>
|
||||
<string name="show_sensitive_content_title">Gevoelige inhoud tonen</string>
|
||||
<string name="show_sensitive_content_explainer">Toont waarschuwing wanneer auteur inhoud als gevoelig heeft gemarkeerd</string>
|
||||
<string name="max_hashtag_limit_title">Maximum hashtags per bericht</string>
|
||||
<string name="max_hashtag_limit_explainer">Verbergt berichten met meer hashtags dan deze limiet. Stel in op 0 om uit te schakelen.</string>
|
||||
<string name="hide_community_rules_violations_title">Berichten verbergen die community-regels schenden</string>
|
||||
<string name="hide_community_rules_violations_explainer">Verwijdert berichten uit community-feeds wanneer de community een NIP-9B regelsdocument publiceert en een event daartegen zou falen. Heeft geen effect wanneer een community geen gestructureerde regels heeft.</string>
|
||||
<string name="security_section_filtering_preferences">Filtervoorkeuren</string>
|
||||
<string name="security_section_blocked_content">Geblokkeerde inhoud</string>
|
||||
<string name="security_unlimited">∞</string>
|
||||
<string name="security_blocked_users_empty">Je hebt nog geen gebruikers geblokkeerd.</string>
|
||||
<string name="security_spamming_users_empty">Geen accounts zijn in deze sessie als spam gemarkeerd.</string>
|
||||
<string name="security_hidden_words_empty">Geen verborgen woorden. Voeg hieronder een woord toe om berichten met dat woord te verbergen.</string>
|
||||
<string name="new_reaction_symbol">Nieuw reactie-symbool</string>
|
||||
<string name="no_reaction_type_setup_long_press_to_change">Geen reactietypes ingesteld. Houd ingedrukt om te wijzigen.</string>
|
||||
<string name="zapraiser">Zapraiser</string>
|
||||
@@ -1263,6 +1294,7 @@
|
||||
<string name="spamming_users">Spammers</string>
|
||||
<string name="muted_button">Gedempt. Tik voor geluid</string>
|
||||
<string name="mute_button">Geluid aan. Tik voor dempen</string>
|
||||
<string name="action_unmute">Demping opheffen</string>
|
||||
<string name="skip_back">%d seconden terug</string>
|
||||
<string name="skip_forward">%d seconden vooruit</string>
|
||||
<string name="picture_in_picture">Picture-in-Picture</string>
|
||||
@@ -1282,6 +1314,8 @@
|
||||
<string name="geohash_exclusive_explainer">Alleen volgers van de locatie zien dit bericht.</string>
|
||||
<string name="hashtag_exclusive">Alleen hashtag-exclusief bericht</string>
|
||||
<string name="hashtag_exclusive_explainer">Alleen volgers van de hashtag zien dit bericht.</string>
|
||||
<string name="external_url_scope">Reageer op een website</string>
|
||||
<string name="external_id_scope">Reageer op een externe bron</string>
|
||||
<string name="long_form_reading_minutes">%1$d min lezen</string>
|
||||
<string name="loading_location">Locatie laden…</string>
|
||||
<string name="lack_location_permissions">Geen locatiemachtigingen</string>
|
||||
@@ -1378,6 +1412,9 @@
|
||||
<string name="no_blossom_apps_found_description">Geen Blossom-app gevonden. Installeer een lokale Blossom-app om dit bestand te bekijken.</string>
|
||||
<string name="hidden_words">Verborgen woorden</string>
|
||||
<string name="hide_new_word_label">Nieuwe woorden of zinnen verbergen</string>
|
||||
<string name="settings_muted_threads_title">Gedempte discussies</string>
|
||||
<string name="settings_muted_threads_empty">Geen gedempte discussies</string>
|
||||
<string name="settings_muted_threads_unknown">Onbekende discussie · %1$s</string>
|
||||
<string name="automatically_show_profile_picture">Profielfoto</string>
|
||||
<string name="automatically_show_profile_picture_description">Profielfoto\'s tonen</string>
|
||||
<string name="select_an_option">Selecteer een optie</string>
|
||||
@@ -1607,6 +1644,12 @@
|
||||
<string name="home_tabs_settings">Start-tabbladen</string>
|
||||
<string name="home_tabs_settings_description">Kies welke tabbladen op het startscherm verschijnen. Wanneer slechts één tab actief is, wordt de tabbalk verborgen.</string>
|
||||
<string name="home_tab_everything">Alles</string>
|
||||
<string name="profile_ui_settings">Profiel-weergave</string>
|
||||
<string name="profile_ui_settings_description">Kies welke secties en feeds op gebruikersprofielschermen verschijnen. Standaard zijn alle opties ingeschakeld.</string>
|
||||
<string name="profile_ui_setting_badges">Profielbadges</string>
|
||||
<string name="profile_ui_setting_app_recommendations">App-aanbevelingen</string>
|
||||
<string name="profile_ui_setting_zap_received_feed">Ontvangen zaps-feed</string>
|
||||
<string name="profile_ui_setting_followers_feed">Volgers-feed</string>
|
||||
<string name="reactions_settings">Reactierij</string>
|
||||
<string name="reactions_settings_description">Configureer welke reactieknoppen worden getoond, hun volgorde en of tellers worden weergegeven.</string>
|
||||
<string name="reactions_settings_enabled">Ingeschakeld</string>
|
||||
@@ -1641,6 +1684,8 @@
|
||||
<string name="video_player_settings_action_download_description">Video downloaden naar je apparaat (verborgen bij livestreams)</string>
|
||||
<string name="video_player_settings_action_pip">Picture-in-Picture</string>
|
||||
<string name="video_player_settings_action_pip_description">Video in zwevend venster afspelen (verborgen als niet ondersteund)</string>
|
||||
<string name="video_player_settings_action_cast">Casten naar apparaat</string>
|
||||
<string name="video_player_settings_action_cast_description">Video casten naar een Chromecast-ontvanger op je wifi (verborgen bij lokale bestanden)</string>
|
||||
<string name="profile_image_of_user">Profielfoto van %1$s</string>
|
||||
<string name="relay_info">Relay %1$s</string>
|
||||
<string name="expand_relay_list">Relay-lijst uitvouwen</string>
|
||||
@@ -1759,6 +1804,18 @@
|
||||
<string name="git_repository">Git-repository: %1$s</string>
|
||||
<string name="git_web_address">Web:</string>
|
||||
<string name="git_clone_address">Klonen:</string>
|
||||
<string name="git_status_open">Openen</string>
|
||||
<string name="git_status_merged">Samengevoegd</string>
|
||||
<string name="git_status_closed">Gesloten</string>
|
||||
<string name="git_status_draft">Concept</string>
|
||||
<string name="git_repo_tab_overview">Overzicht</string>
|
||||
<string name="git_repo_tab_issues">Problemen</string>
|
||||
<string name="git_repo_tab_patches">Patches & PRs</string>
|
||||
<string name="git_repo_section_about">Over</string>
|
||||
<string name="git_repo_section_links">Links</string>
|
||||
<string name="git_repo_section_maintainers">Beheerders</string>
|
||||
<string name="git_repo_section_topics">Onderwerpen</string>
|
||||
<string name="git_repo_personal_fork">Persoonlijke fork</string>
|
||||
<string name="nsite_title">Statische website: %1$s</string>
|
||||
<string name="nsite_root_site">Root-site</string>
|
||||
<string name="nsite_source">Bron:</string>
|
||||
@@ -1856,8 +1913,11 @@
|
||||
<string name="favorite_dvms_title">Favoriete feed-algoritmes</string>
|
||||
<string name="favorite_dvms_explainer">Feed-algoritmes die je hier hebt gesterd, verschijnen als filterchips op de startfeed. Open Ontdekken om meer toe te voegen.</string>
|
||||
<string name="favorite_dvms_empty_headline">Pin je favoriete algoritmes</string>
|
||||
<string name="favorite_dvms_empty_step1">Tik op “%1$s” hieronder om algoritmes te bekijken.</string>
|
||||
<!-- %1$s is replaced at runtime by an inline star icon, not text. Keep the placeholder. -->
|
||||
<string name="favorite_dvms_empty_step2">Tik op het %1$s naast een feed om hem hier te bewaren.</string>
|
||||
<string name="favorite_dvms_empty_cta">Feeds toevoegen</string>
|
||||
<string name="favorite_dvms_add_more">Meer toevoegen…</string>
|
||||
<string name="dvm_home_status_requesting">%1$s vragen voor een feed…</string>
|
||||
<string name="dvm_home_status_requesting_all">Je favoriete feed-algoritmes vragen voor feeds…</string>
|
||||
<string name="dvm_home_status_processing">Je feed verwerken…</string>
|
||||
@@ -2250,6 +2310,10 @@
|
||||
<string name="playback_actions_dialog_title">Afspelen</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<string name="cast_to_device">Casten naar apparaat</string>
|
||||
<string name="cast_stop_casting">Casten stoppen</string>
|
||||
<string name="cast_to_device_dialog_title">Casten naar…</string>
|
||||
<string name="cast_searching_for_devices">Zoeken naar apparaten op je wifi…</string>
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<string name="share_hls_video">HLS-upload</string>
|
||||
<string name="share_hls_video_drawer_description">Publiceer multi-resolutie HLS naar je mediaserver</string>
|
||||
@@ -2448,6 +2512,9 @@
|
||||
<string name="ai_writing_setting_description">Gebruikt een on-device AI-model om tekstcorrecties en toonwijzigingen voor te stellen.</string>
|
||||
<string name="tracked_broadcasts_setting_title">Getrackte uitzendingen</string>
|
||||
<string name="tracked_broadcasts_setting_description">Gebruik de tracked broadcaster bij het verzenden van events. Toont live voortgang en per-relay-status tijdens uitzenden.</string>
|
||||
<string name="compose_settings">Opstel-instellingen</string>
|
||||
<string name="auto_create_drafts_setting_title">Automatisch concepten aanmaken</string>
|
||||
<string name="auto_create_drafts_setting_description">Slaat automatisch een concept op wanneer je typt of de opsteller verlaat met onverzonden tekst en stuurt dit naar je privé outbox-relays.</string>
|
||||
<string name="ai_writing_use_this">Gebruik dit</string>
|
||||
<string name="ai_writing_dismiss">Sluiten</string>
|
||||
<string name="ai_tone_correct">Corrigeren</string>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<string name="show_anyway">Show Anyway</string>
|
||||
<string name="post_was_hidden">This post was hidden because it mentions your hidden users or words</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">Post was muted or reported by</string>
|
||||
<string name="post_was_hidden_due_to_too_many_hashtags">This post has more than %1$d hashtags</string>
|
||||
<string name="post_not_found">Event is loading or can\'t be found in your relay list</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<string name="channel_image">Channel Image</string>
|
||||
@@ -1541,6 +1542,7 @@
|
||||
<string name="copy_stack_to_clipboard">Copy Stack</string>
|
||||
|
||||
<string name="copy_to_clipboard">Copy to clipboard</string>
|
||||
<string name="copied_to_clipboard">Copied to clipboard</string>
|
||||
<string name="copy_nprofile_to_clipboard">Copy nprofile to clipboard</string>
|
||||
<string name="copy_npub_to_clipboard">Copy npub to clipboard</string>
|
||||
<string name="share_or_save">Share or Save</string>
|
||||
@@ -1674,9 +1676,18 @@
|
||||
<string name="read_only_user">Read-only user</string>
|
||||
<string name="no_reactions_setup">No reactions setup</string>
|
||||
|
||||
<string name="notification_settings">Notifications</string>
|
||||
<string name="notification_settings_section_delivery">Delivery</string>
|
||||
<string name="notification_settings_section_display">In-app display</string>
|
||||
<string name="notification_settings_section_categories">Categories</string>
|
||||
<string name="notification_settings_categories_explainer">Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there.</string>
|
||||
<string name="notification_channel_status_on">On</string>
|
||||
<string name="notification_channel_status_silent">Silent</string>
|
||||
<string name="notification_channel_status_off">Off</string>
|
||||
|
||||
<string name="select_push_server">Select a UnifiedPush App</string>
|
||||
<string name="push_server_title">Push Notification</string>
|
||||
<string name="push_server_explainer">From installed UnifiedPush apps</string>
|
||||
<string name="push_server_title">Push provider</string>
|
||||
<string name="push_server_explainer">Pick a UnifiedPush app to deliver notifications when Amethyst is closed.</string>
|
||||
<string name="push_server_none">None</string>
|
||||
<string name="push_server_none_explainer">Disables Push Notifications</string>
|
||||
<string name="push_server_uses_app_explainer">Uses app %1$s</string>
|
||||
|
||||
+3
-1
@@ -52,4 +52,6 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {}
|
||||
fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {}
|
||||
|
||||
fun hasPushNotificationProvider(): Boolean = false
|
||||
|
||||
+164
@@ -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 = "_",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -104,13 +104,20 @@ private fun bridgeUrl(
|
||||
if (url.startsWith("blossom:", ignoreCase = true)) return url
|
||||
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
|
||||
|
||||
val sha =
|
||||
explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) }
|
||||
?: extractSha256FromUrlPath(url)
|
||||
?: return url
|
||||
// The local Blossom cache fetches `<xs>/<sha>.<ext>` on miss per BUD-01,
|
||||
// which only works when the upstream URL is itself BUD-01 layout. For
|
||||
// non-BUD-01 URLs (e.g. https://i.nostr.build/M5AwJ.gif) the imeta `x`
|
||||
// hash identifies the blob but the upstream server doesn't host it at
|
||||
// /<sha>.<ext>, so trusting only `explicitHash` would point the cache
|
||||
// at a 404. Require the sha to be in the URL path before bridging.
|
||||
val urlSha = extractSha256FromUrlPath(url) ?: return url
|
||||
|
||||
// Prefer the imeta hash when it's a valid sha256 (authoritative casing),
|
||||
// otherwise fall back to what was parsed from the URL.
|
||||
val sha = explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) } ?: urlSha
|
||||
|
||||
val ext = guessExtension(url, mimeType)
|
||||
val serverBase = extractServerBase(url, sha) ?: return url
|
||||
val serverBase = extractServerBase(url, urlSha) ?: return url
|
||||
|
||||
val authors =
|
||||
authorPubKey
|
||||
|
||||
+24
-5
@@ -80,14 +80,15 @@ class MediaUrlContentExtTest {
|
||||
|
||||
@Test
|
||||
fun bridgeOnInfersExtensionFromMimeType() {
|
||||
val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha, mimeType = "image/png")
|
||||
// BUD-01 allows `<sha>` without an extension; mimeType supplies one.
|
||||
val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha, mimeType = "image/png")
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertTrue(result.startsWith("blossom:$sha.png?xs="), "expected png extension from mime, got $result")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnFallsBackToBinExtension() {
|
||||
val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha)
|
||||
val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertTrue(result.startsWith("blossom:$sha.bin?xs="), "expected bin extension fallback, got $result")
|
||||
}
|
||||
@@ -100,7 +101,7 @@ class MediaUrlContentExtTest {
|
||||
|
||||
@Test
|
||||
fun uppercaseHashNormalisedToLowercase() {
|
||||
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = sha.uppercase())
|
||||
val image = MediaUrlImage(url = "https://cdn.example.com/${sha.uppercase()}.jpg", hash = sha.uppercase())
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result")
|
||||
}
|
||||
@@ -110,7 +111,7 @@ class MediaUrlContentExtTest {
|
||||
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
|
||||
val image =
|
||||
MediaUrlImage(
|
||||
url = "https://cdn.example.com/foo.jpg",
|
||||
url = "https://cdn.example.com/$sha.jpg",
|
||||
hash = sha,
|
||||
authorPubKey = authorPub,
|
||||
)
|
||||
@@ -122,7 +123,7 @@ class MediaUrlContentExtTest {
|
||||
fun invalidAuthorPubKeyDropped() {
|
||||
val image =
|
||||
MediaUrlImage(
|
||||
url = "https://cdn.example.com/foo.jpg",
|
||||
url = "https://cdn.example.com/$sha.jpg",
|
||||
hash = sha,
|
||||
authorPubKey = "not-a-pubkey",
|
||||
)
|
||||
@@ -130,6 +131,24 @@ class MediaUrlContentExtTest {
|
||||
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnSkipsNonBud01UrlEvenWithImetaHash() {
|
||||
// The imeta `x` hash refers to a blob whose canonical Blossom location
|
||||
// is /<sha>.<ext>, but the upstream URL serves it under a different
|
||||
// path (https://i.nostr.build/M5AwJ.gif). Routing this through the
|
||||
// local cache would set xs=https://i.nostr.build, and the cache would
|
||||
// fetch https://i.nostr.build/<sha>.gif on miss, which 404s.
|
||||
val url = "https://i.nostr.build/M5AwJ.gif"
|
||||
val image = MediaUrlImage(url = url, hash = sha)
|
||||
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlSkipsNonBud01UrlEvenWithImetaHash() {
|
||||
val url = "https://i.nostr.build/M5AwJ.gif"
|
||||
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlNullReturnsNull() {
|
||||
assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true))
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,16 @@
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# kmp-tor — loads native Tor daemon via JNI reflection
|
||||
-keep class io.matthewnelson.** { *; }
|
||||
|
||||
# Coil image loader — uses ServiceLoader for decoder/fetcher registration
|
||||
-keep class coil3.** { *; }
|
||||
|
||||
# OkHttp/Okio — platform detection and I/O via reflection
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep class okio.** { *; }
|
||||
|
||||
# ============================================================================
|
||||
# Optimize sub-pass — disable the one that produces invalid okio bytecode
|
||||
# ============================================================================
|
||||
@@ -185,3 +195,9 @@
|
||||
# to detect logging. We ship slf4j-nop; keep it intact so detection succeeds.
|
||||
-keep class org.slf4j.** { *; }
|
||||
-dontwarn org.slf4j.**
|
||||
|
||||
# ============================================================================
|
||||
# Kotlin 2.3 stdlib stubs — compile-time classes with no JVM runtime class
|
||||
# ============================================================================
|
||||
-dontwarn kotlin.concurrent.atomics.**
|
||||
-dontwarn kotlin.jvm.internal.EnhancedNullability
|
||||
|
||||
+27
-2
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import com.sun.jna.Function
|
||||
import com.sun.jna.NativeLibrary
|
||||
import uk.co.caprica.vlcj.binding.lib.LibC
|
||||
import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil
|
||||
import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy
|
||||
|
||||
@@ -36,6 +36,14 @@ class MacOsVlcDiscoverer :
|
||||
arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"),
|
||||
arrayOf("%s/plugins"),
|
||||
) {
|
||||
/** Plugin path discovered during [setPluginPath], available after discovery. */
|
||||
var discoveredPluginPath: String? = null
|
||||
private set
|
||||
|
||||
/** Whether [setPluginPath] successfully set the process env var. */
|
||||
var envVarSet: Boolean = false
|
||||
private set
|
||||
|
||||
override fun supported(): Boolean {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
return "mac" in os
|
||||
@@ -52,5 +60,22 @@ class MacOsVlcDiscoverer :
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setPluginPath(pluginPath: String?): Boolean = LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0
|
||||
override fun setPluginPath(pluginPath: String?): Boolean {
|
||||
if (pluginPath == null) return false
|
||||
discoveredPluginPath = pluginPath
|
||||
return try {
|
||||
// Call setenv directly via JNA Function API. This bypasses vlcj's
|
||||
// LibC interface binding which fails on macOS 13+ because dlsym
|
||||
// can't resolve the versioned symbol `setenv$3b99ba0d`.
|
||||
val setenv = Function.getFunction("c", "setenv")
|
||||
val result = setenv.invokeInt(arrayOf<Any>(PLUGIN_ENV_NAME, pluginPath, 1)) == 0
|
||||
envVarSet = result
|
||||
result
|
||||
} catch (_: Throwable) {
|
||||
// JNA Function call also failed — VlcjPlayerPool will use
|
||||
// --plugin-path factory arg as fallback.
|
||||
envVarSet = false
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-3
@@ -55,6 +55,9 @@ object VlcjPlayerPool {
|
||||
private val idleThumbPlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
|
||||
private const val MAX_THUMB_POOL_SIZE = 2
|
||||
|
||||
// Cached plugin path for audio factory creation (set during init)
|
||||
private var cachedPluginPath: String? = null
|
||||
|
||||
// Audio player pool (shared factory with --no-video)
|
||||
private var audioFactory: MediaPlayerFactory? = null
|
||||
private val allAudioPlayers = mutableListOf<MediaPlayer>()
|
||||
@@ -76,12 +79,13 @@ object VlcjPlayerPool {
|
||||
|
||||
return try {
|
||||
// Try bundled VLC first, then fall through to system VLC
|
||||
val macOsDiscoverer = MacOsVlcDiscoverer()
|
||||
val discovery =
|
||||
try {
|
||||
val nd =
|
||||
NativeDiscovery(
|
||||
BundledVlcDiscoverer(),
|
||||
MacOsVlcDiscoverer(),
|
||||
macOsDiscoverer,
|
||||
)
|
||||
val found = nd.discover()
|
||||
if (found) {
|
||||
@@ -99,7 +103,34 @@ object VlcjPlayerPool {
|
||||
val systemDiscovery = NativeDiscovery().discover()
|
||||
println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}")
|
||||
}
|
||||
val f = MediaPlayerFactory("--no-xlib")
|
||||
|
||||
// Delete stale VLC plugin cache on macOS to avoid spam warnings
|
||||
if ("mac" in System.getProperty("os.name").lowercase()) {
|
||||
try {
|
||||
val cacheDir = java.io.File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc")
|
||||
cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() }
|
||||
} catch (_: Throwable) {
|
||||
// Best-effort cache cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Build factory args — add --plugin-path fallback if env var wasn't set
|
||||
val factoryArgs = mutableListOf("--no-xlib")
|
||||
if (!macOsDiscoverer.envVarSet) {
|
||||
val pluginPath =
|
||||
macOsDiscoverer.discoveredPluginPath
|
||||
?: System.getProperty("vlc.plugin.path")
|
||||
?: VlcResourceResolver.findVlcDir()?.let { "${it.absolutePath}/plugins" }
|
||||
if (pluginPath != null) {
|
||||
factoryArgs += "--plugin-path=$pluginPath"
|
||||
println("VLC: using --plugin-path fallback: $pluginPath")
|
||||
}
|
||||
}
|
||||
|
||||
cachedPluginPath = macOsDiscoverer.discoveredPluginPath
|
||||
?: System.getProperty("vlc.plugin.path")
|
||||
|
||||
val f = MediaPlayerFactory(*factoryArgs.toTypedArray())
|
||||
factory = f
|
||||
available.set(true)
|
||||
println("VLC: MediaPlayerFactory created successfully")
|
||||
@@ -184,7 +215,9 @@ object VlcjPlayerPool {
|
||||
|
||||
val af =
|
||||
audioFactory ?: try {
|
||||
MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it }
|
||||
val audioArgs = mutableListOf("--no-video", "--no-xlib")
|
||||
cachedPluginPath?.let { audioArgs += "--plugin-path=$it" }
|
||||
MediaPlayerFactory(*audioArgs.toTypedArray()).also { audioFactory = it }
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
|
||||
+9
-5
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
@@ -50,7 +51,10 @@ class RelayAuthenticator(
|
||||
val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
val signWithAllLoggedInUsers: suspend (EventTemplate<RelayAuthEvent>) -> List<RelayAuthEvent>,
|
||||
) : IAuthStatus {
|
||||
private val authStatus = mutableMapOf<NormalizedRelayUrl, RelayAuthStatus>()
|
||||
// Connection callbacks fire on the per-relay OkHttp dispatcher thread, so
|
||||
// this state is mutated concurrently — LargeCache wraps a platform-tuned
|
||||
// concurrent map (ConcurrentSkipListMap on jvmAndroid, CacheMap on Apple).
|
||||
private val authStatus = LargeCache<NormalizedRelayUrl, RelayAuthStatus>()
|
||||
|
||||
private val clientListener =
|
||||
object : RelayConnectionListener {
|
||||
@@ -66,7 +70,7 @@ class RelayAuthenticator(
|
||||
}
|
||||
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
authStatus[relay.url] = RelayAuthStatus()
|
||||
authStatus.put(relay.url, RelayAuthStatus())
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
@@ -82,7 +86,7 @@ class RelayAuthenticator(
|
||||
val ev = RelayAuthEvent.build(relay.url, msg.challenge)
|
||||
signWithAllLoggedInUsers(ev).forEach { authEvent ->
|
||||
// only send replies to new challenges to avoid infinite loop:
|
||||
if (authStatus[relay.url]?.saveAuthSubmission(authEvent) == true) {
|
||||
if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) {
|
||||
relay.sendIfConnected(AuthCmd(authEvent))
|
||||
}
|
||||
}
|
||||
@@ -94,12 +98,12 @@ class RelayAuthenticator(
|
||||
msg: OkMessage,
|
||||
) {
|
||||
// if this is the OK of an auth event, renew all subscriptions and resend all outgoing events.
|
||||
if (authStatus[relay.url]?.checkAuthResults(msg.eventId, msg.success) == true) {
|
||||
if (authStatus.get(relay.url)?.checkAuthResults(msg.eventId, msg.success) == true) {
|
||||
client.syncFilters(relay)
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = authStatus[relay]?.hasFinishedAllAuths() != false
|
||||
override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = authStatus.get(relay)?.hasFinishedAllAuths() != false
|
||||
|
||||
init {
|
||||
Log.d("RelayAuthenticator", "Init, Subscribe")
|
||||
|
||||
+22
@@ -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),
|
||||
)
|
||||
|
||||
+5
-2
@@ -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) {
|
||||
|
||||
+75
@@ -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())
|
||||
}
|
||||
}
|
||||
+126
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user