diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 3a2f13cb53..23d62797ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -329,6 +329,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -2015,10 +2016,10 @@ class Account( val rootEvent = rootNote.event ?: return false gatherers?.firstNotNullOfOrNull { it as? PublicChatChannel }?.let { chat -> - val relays = chat.relays().ifEmpty { outboxRelays.flow.value } - val signed = signer.sign(CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, chat.relays().firstOrNull()))) + val relays = chat.relays() + val signed = signer.sign(CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, relays.firstOrNull()))) cache.justConsumeMyOwnEvent(signed) - client.publish(signed, relays) + client.publish(signed, relays.ifEmpty { outboxRelays.flow.value }) return true } @@ -4804,9 +4805,12 @@ class Account( // Keep Concord channel metadata (community name/icon, membership) live across the whole // app — not just the hub screen — so the Messages tab renders each channel's community - // chip, and per-community bans apply, as soon as a Control Plane folds. + // chip, and per-community bans apply, as soon as a Control Plane folds. The revision bumps + // on every ingested message, so sample() coalesces bursts into at most one full re-index + // per window instead of re-scanning every channel's notes per message. scope.launch { - concordSessions.revision.collect { refreshConcordChannelIndex() } + @OptIn(kotlinx.coroutines.FlowPreview::class) + concordSessions.revision.sample(500).collect { refreshConcordChannelIndex() } } scope.launch { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 1db7d59e8f..0c69c359fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator @@ -33,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope +import java.util.concurrent.ConcurrentHashMap class ScreenAuthAccount( val account: Account, @@ -87,10 +90,13 @@ class AuthCoordinator( } val currentLedgers = relayLedgers // Ask the user (only in the ASK case) and fold every account's verdict into one - // decision plus an optional per-relay override to remember. + // decision plus an optional per-relay override to remember. When this relay hosts our + // Concord planes, never block the derived stream-key AUTH behind a user prompt: skip + // the user-auth ASK (DISMISS) so we return the stream AUTHs immediately instead of + // waiting on — or being dropped by — a dialog the user may never answer. val outcome = AuthDecisionResolver.resolve(currentLedgers.map { it.decide(context) }) { - promptBus.requestDecision(relayUrl, context.purposes) + if (streamAuths.isNotEmpty()) UserAuthChoice.DISMISS else promptBus.requestDecision(relayUrl, context.purposes) } outcome.remember?.let { decision -> currentLedgers.firstOrNull()?.setDecision(relayUrl.url, decision) @@ -146,7 +152,9 @@ class AuthCoordinator( if (secrets.isEmpty()) return emptyList() return secrets.mapNotNull { secret -> try { - NostrSignerSync(KeyPair(privKey = secret)).sign(authTemplate) + // Cache the signer by secret so we don't re-derive the secp256k1 keypair for every + // plane on every relay challenge/reconnect. + streamSigners.getOrPut(secret.toHexKey()) { NostrSignerSync(KeyPair(privKey = secret)) }.sign(authTemplate) } catch (e: Exception) { Log.e("AuthCoordinator", "Failed to sign a Concord stream-key AUTH", e) null @@ -154,6 +162,9 @@ class AuthCoordinator( } } + // stream secret (hex) -> its local signer. Bounded by joined communities × channels. + private val streamSigners = ConcurrentHashMap() + fun destroy() { receiver.destroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 3cc84fe57f..4291038c01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -434,15 +434,6 @@ open class ChannelNewMessageViewModel : private suspend fun createTemplate(): EventTemplate? { val channel = channel ?: return null - // A minichat reply is a kind-1111 thread comment rooted at the parent, independent of the - // channel type; NIP-29 groups additionally carry the `h` tag so the relay scopes it. - val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event - if (minichatParent != null) { - return CommentEvent.replyBuilder(message.text.toString(), EventHintBundle(minichatParent, channel.relays().firstOrNull())) { - if (channel is RelayGroupChannel) hTag(channel.groupId.id) - } - } - val messageText = message.text.toString() val tagger = NewMessageTagger( @@ -463,6 +454,25 @@ open class ChannelNewMessageViewModel : val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null + // A minichat reply is a kind-1111 thread comment rooted at the parent, independent of the + // channel type (NIP-29 groups additionally carry the `h` tag). It carries the same mention/ + // hashtag/quote/emoji/attachment enrichment an inline message does — built from tagger.message, + // not the raw text — so replying in a thread never silently drops any of them. + val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event + if (minichatParent != null) { + return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) { + if (channel is RelayGroupChannel) hTag(channel.groupId.id) + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } + geoHash?.let { geohash(it) } + emojis(emojis) + imetas(usedAttachments) + } + } + return when { channel is PublicChatChannel -> { val replyingToEvent = replyTo.value?.toEventHint()