diff --git a/.claude/hooks/pre-push-spotless.sh b/.claude/hooks/pre-push-spotless.sh index ff989f68a6..782d769d18 100755 --- a/.claude/hooks/pre-push-spotless.sh +++ b/.claude/hooks/pre-push-spotless.sh @@ -64,9 +64,13 @@ before="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)" log="$(mktemp /tmp/spotless-gate.XXXXXX.log)" if ! ./gradlew spotlessApply >"$log" 2>&1; then # Distinguish a formatting failure (block) from Gradle being unable to RUN — - # e.g. deps can't resolve in a restricted sandbox. An infra failure must not - # strand the agent; warn and let CI's spotlessCheck be the backstop. - if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out" "$log"; then + # e.g. deps can't resolve, or the wrapper can't even download the Gradle + # distribution, in a restricted sandbox. An infra failure must not strand the + # agent; warn and let CI's spotlessCheck be the backstop. The distribution + # patterns (the `gradle--bin.zip` URL, the Java download exception, and a + # proxy 40x on that fetch) only appear when `./gradlew` failed *before* running + # any task, so they can't mask a real formatting/compile error. + if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out|server returned http response code|gradle-[0-9][0-9.]*-(bin|all)\.zip|could not install gradle" "$log"; then echo "WARN: could not run spotlessApply (Gradle infra/network failure), skipping the formatting gate." >&2 echo " CI's spotlessCheck still enforces formatting on the PR." >&2 rm -f "$log" diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 5a7960c004..42e59a2bb1 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -384,6 +384,17 @@ android:value="Persistent real-time messaging relay connection for Nostr protocol. Maintains WebSocket connections to user-configured inbox relays for immediate notification delivery of direct messages, zaps, and mentions." /> + + + + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 393e9b64d8..22d08179ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -122,6 +122,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker +import com.vitorpamplona.quartz.nip01Core.relay.client.limits.RelayLimitsTracker import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder @@ -710,6 +711,9 @@ class AppModules( // Captures statistics about relays val relayStats = RelayStats(client) + // Caches the latest LIMITS (rights + limits) each relay advertises. + val relayLimits = RelayLimitsTracker(client) + // Resource-usage ledger: relay traffic/reconnect + connection-time, // foreground-time, process-CPU, and signature-verification collectors. init { @@ -1100,11 +1104,13 @@ class AppModules( } } - // Watch for account login and start/stop always-on notification service + // Watch for account login and start/stop always-on notification service. + // The manager gates on the global master switch + each account's participation + // (not the active account), so it only needs to run while someone is logged in. applicationIOScope.launch { sessionManager.accountContent.collectLatest { state -> if (state is AccountState.LoggedIn) { - alwaysOnNotificationServiceManager.watchAccount(state.account) + alwaysOnNotificationServiceManager.start() } else { alwaysOnNotificationServiceManager.stop() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index b437a6e838..3f17440e3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -71,6 +71,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -94,6 +95,11 @@ data class AccountInfo( private object PrefKeys { const val CURRENT_ACCOUNT = "currently_logged_in_account" + + // Global (non-account) master switch for the always-on notification service. + // When off, the service is suppressed for every account regardless of each + // account's own participation flag. Persisted so it survives restarts/crashes. + const val NOTIFICATION_SERVICE_ENABLED = "notification_service_enabled" const val SAVED_ACCOUNTS = "all_saved_accounts" const val NOSTR_PRIVKEY = "nostr_privkey" const val NOSTR_PUBKEY = "nostr_pubkey" @@ -205,6 +211,36 @@ object LocalPreferences { private val savedAccountsMutex = Mutex() private val cachedAccounts: MutableMap = mutableMapOf() + // Global master switch for the always-on notification service ("Background + // notification service"). Default ON: existing users keep current behavior, and + // per-account participation decides who actually stays active. + // + // Stored in PLAIN (non-encrypted) SharedPreferences on purpose. It is a non-sensitive + // global boolean, and — unlike encryptedPreferences(), which asserts non-main — plain + // prefs can be read synchronously on ANY thread. The restart-layer gate + // (NotificationRelayService.isEnabled) is synchronous and runs in fresh processes (boot + // receiver, WorkManager), so it MUST read the persisted value without a suspend hop; + // otherwise a saved OFF would be missed on cold boot and the service would resurrect. + // The flow is lazily seeded from disk once (synchronous, main-safe) and is thereafter + // the source of truth, so there is no async hydrate that could clobber a user toggle. + private fun globalSettingsPrefs(): SharedPreferences = Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE) + + private val notificationServiceEnabled: MutableStateFlow by lazy { + MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)) + } + + fun notificationServiceEnabledFlow(): StateFlow = notificationServiceEnabled + + fun isNotificationServiceEnabled(): Boolean = notificationServiceEnabled.value + + fun setNotificationServiceEnabled(enabled: Boolean) { + // In-memory update is the source of truth (main-safe); plain-prefs edit{} persists + // asynchronously via apply(), also main-safe. No suspend/hydrate hop, so no window + // where a late disk read can clobber this write. + notificationServiceEnabled.value = enabled + globalSettingsPrefs().edit { putBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, enabled) } + } + suspend fun currentAccount(): String? { if (currentAccount == null) { currentAccount = 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 9451ea5a7a..f94e7e73b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -157,6 +157,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope @@ -255,6 +256,8 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent @@ -2039,20 +2042,34 @@ class Account( * Redeem a Concord invite link (`…/invite/#`): parse it, fetch * the kind-33301 public bundle from the link's relays (+ our outbox), unlock it * with the fragment token, and add the resulting secret-bearing entry to the - * kind-13302 joined list. Returns the joined community id, or null if the link - * is invalid, unreadable, or no valid bundle is found. + * kind-13302 joined list. + * + * Returns a [ConcordInviteResult] that separates the failure modes so the UI can + * both explain what went wrong and decide whether a retry could ever help — a + * bundle we can't open (e.g. minted by a newer client) must not strand the user + * on a spinner that retries forever. */ - suspend fun joinConcordViaInvite(url: String): String? { - if (!isWriteable()) return null - val parsed = ConcordActions.parseInviteLink(url) ?: return null + suspend fun joinConcordViaInvite(url: String): ConcordInviteResult { + if (!isWriteable()) return ConcordInviteResult.InvalidLink + val parsed = ConcordActions.parseInviteLink(url) ?: return ConcordInviteResult.InvalidLink val relays = (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet() - if (relays.isEmpty()) return null + if (relays.isEmpty()) return ConcordInviteResult.NotReachable val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } val wraps = client.fetchAll(filters = filters) - val bundle = wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } ?: return null + + // Resolve the coordinate per CORD-05 §2 (newest wins; a vsk=9 tombstone revokes even over a + // stale openable copy) so we honour revocation and can tell the user *why* a link won't open + // instead of stranding them on a spinner that retries a link we can never redeem. + val bundle = + when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { + is InviteBundleStatus.Live -> status.invite + InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked + InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible + InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable + } val entry = ConcordCommunityListEntry( @@ -2066,7 +2083,7 @@ class Account( addedAt = TimeUtils.now() * 1000, ) joinConcordCommunity(entry) - return bundle.communityId + return ConcordInviteResult.Joined(bundle.communityId) } /** @@ -2176,6 +2193,7 @@ class Account( signer.sign( CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) { hTag(group.groupId.id) + previous(group.previousEventRefs(pubKey)) }, ) cache.justConsumeMyOwnEvent(signed) @@ -2703,6 +2721,7 @@ class Account( isRestricted: Boolean = false, hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = null, ): GroupId { signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) } @@ -2715,6 +2734,7 @@ class Account( status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), hashtags = hashtags, geohashes = geohashes, + parent = parent, ) signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } @@ -2747,7 +2767,11 @@ class Account( title: String, body: String, ) { - val template = ThreadEvent.build(body, title) { hTag(channel.groupId.id) } + val template = + ThreadEvent.build(body, title) { + hTag(channel.groupId.id) + previous(channel.previousEventRefs(pubKey)) + } signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } @@ -2760,6 +2784,37 @@ class Account( signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } + /** + * Replace the group's pinned-message list with a kind 9010 update-pin-list event + * (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and + * republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent]. + */ + suspend fun updateRelayGroupPins( + channel: RelayGroupChannel, + pinnedEventIds: List, + ) { + val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds) + signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** Pin [eventId] by appending it to the current list (no-op if already pinned). */ + suspend fun pinRelayGroupMessage( + channel: RelayGroupChannel, + eventId: HexKey, + ) { + if (channel.isPinned(eventId)) return + updateRelayGroupPins(channel, channel.pinnedEventIds + eventId) + } + + /** Unpin [eventId] by removing it from the current list (no-op if not pinned). */ + suspend fun unpinRelayGroupMessage( + channel: RelayGroupChannel, + eventId: HexKey, + ) { + if (!channel.isPinned(eventId)) return + updateRelayGroupPins(channel, channel.pinnedEventIds - eventId) + } + /** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */ suspend fun removeRelayGroupUser( channel: RelayGroupChannel, @@ -2782,7 +2837,16 @@ class Account( signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } - /** Edit the group's relay-signed metadata with a kind 9002 event (admin only). */ + /** + * Edit the group's relay-signed metadata with a kind 9002 event (admin only). + * + * NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy + * links: a 9002 with no `parent` tag re-roots the group, and one that drops any + * existing `child` is rejected by the relay. So unless the caller is explicitly + * re-parenting, we re-carry the group's current [parent] and full [children] list + * from its latest known metadata to keep the tree intact across a plain name/flag + * edit. Pass an explicit value to change them. + */ suspend fun editRelayGroupMetadata( channel: RelayGroupChannel, name: String?, @@ -2794,6 +2858,8 @@ class Account( isRestricted: Boolean, hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = channel.parentGroupId(), + children: List = channel.childGroupIds(), ) { val template = EditMetadataEvent.build( @@ -2804,6 +2870,8 @@ class Account( status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), hashtags = hashtags, geohashes = geohashes, + parent = parent, + children = children, ) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } @@ -4707,6 +4775,14 @@ class Account( sendMyPublicAndPrivateOutbox(muteList.showWord(word)) } + suspend fun hideHashtag(hashtag: String) { + sendMyPublicAndPrivateOutbox(muteList.hideHashtag(hashtag)) + } + + suspend fun showHashtag(hashtag: String) { + muteList.showHashtag(hashtag)?.let { sendMyPublicAndPrivateOutbox(it) } + } + suspend fun hideUser(pubkeyHex: HexKey) { sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt new file mode 100644 index 0000000000..6c9cc50222 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt @@ -0,0 +1,56 @@ +/* + * 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.model + +/** + * The outcome of redeeming a Concord invite link (CORD-05). Separating the failure + * modes lets the UI tell the user *why* it failed and — crucially — whether + * retrying could ever help, so a link we can never open doesn't strand the user on + * an endless "redeeming…" spinner with a retry button that loops forever. + */ +sealed interface ConcordInviteResult { + /** Redeemed and joined; navigate to [communityId]. */ + data class Joined( + val communityId: String, + ) : ConcordInviteResult + + /** The link itself is malformed, or this account can't join (read-only key). Retrying can't help. */ + data object InvalidLink : ConcordInviteResult + + /** + * No invite bundle was reachable on any relay — a transient miss (relays down, + * link too new to have propagated, or expired). Retrying may help. + */ + data object NotReachable : ConcordInviteResult + + /** + * The link was revoked: the newest event at its coordinate is a `vsk=9` revocation + * tombstone (CORD-05 §2). Retrying can't help — the owner retired this link. + */ + data object Revoked : ConcordInviteResult + + /** + * The bundle event was found but could not be opened with the link's token — + * typically because it was minted by a newer/incompatible Concord client whose + * bundle format this app can't read yet. Retrying can't help. + */ + data object Incompatible : ConcordInviteResult +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 4b7c5b3e4a..67be2a34c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -164,6 +164,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent @@ -172,6 +173,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent @@ -797,7 +799,15 @@ object LocalCache : ILocalCache, ICacheProvider { // permanent "Event is loading…" ghost. Drop it; the reverse order (delete after the message) // is already handled by the normal deletion cascade unlinking the note from its gatherers. messageRow?.let { (ch, note) -> - if (note.event == null) ch.removeNote(note) + if (note.event == null) { + ch.removeNote(note) + } else { + // The row was attached (addNote) BEFORE justConsume set the event, so addNote saw a + // null createdAt and could not pick lastNote or order the feed. The event is loaded + // now — refresh so the channel's last-message preview, unread count, and ordering are + // correct (otherwise lastNote stays null forever and every row reads "No messages yet"). + ch.refreshAfterEventLoad(note) + } } } @@ -1875,6 +1885,27 @@ object LocalCache : ILocalCache, ICacheProvider { return new } + /** + * NIP-29 addressables (kinds 39000-39005) are authoritative for a group's metadata, + * roster, roles and pins ONLY when signed by the relay's own key — the NIP-11 `self` + * pubkey. This returns false only when we can positively tell an event is NOT relay-signed + * (the relay advertises a `self` and the event's author differs), so a stray or malicious + * user-published 39000/39001/… served by a lax relay can't overwrite a group's state (e.g. + * inject itself into the admin list). When `self` isn't known yet — the NIP-11 doc hasn't + * loaded, or the relay doesn't advertise one — we don't block, so legitimate groups still + * populate and this never regresses a relay whose key we simply haven't fetched. + */ + private fun isRelaySignedGroupEvent( + event: Event, + relay: NormalizedRelayUrl, + ): Boolean { + val self = + Amethyst.instance.nip11Cache + .getFromCache(relay) + .self ?: return true + return event.pubKey == self + } + /** * NIP-29 relay-signed group metadata (kind 39000). Stored as an addressable * note and used to populate the [RelayGroupChannel]'s name/picture/about/ @@ -1890,7 +1921,7 @@ object LocalCache : ILocalCache, ICacheProvider { ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val note = getOrCreateAddressableNote(event.address()) val channel = getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay)) (note.event as? GroupMetadataEvent)?.let { channel.updateGroupInfo(it, note) } @@ -1906,7 +1937,7 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? GroupMembersEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateMembers(it) } } @@ -1920,13 +1951,41 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? GroupAdminsEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateAdmins(it) } } return new } + /** NIP-29 relay-signed pinned-message list (kind 39005) → the group's pins. */ + fun consume( + event: GroupPinnedEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val new = consumeBaseReplaceable(event, relay, wasVerified) + if (relay != null && isRelaySignedGroupEvent(event, relay)) { + val latest = getOrCreateAddressableNote(event.address()).event as? GroupPinnedEvent + latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updatePinned(it) } + } + return new + } + + /** NIP-29 relay-declared supported roles (kind 39003) → the group's role set. */ + fun consume( + event: SupportedRolesEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val new = consumeBaseReplaceable(event, relay, wasVerified) + if (relay != null && isRelaySignedGroupEvent(event, relay)) { + val latest = getOrCreateAddressableNote(event.address()).event as? SupportedRolesEvent + latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateSupportedRoles(it) } + } + return new + } + /** * Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, … * carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic @@ -3892,16 +3951,22 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } - // Remaining NIP-29 relay-group kinds. The two relay-signed addressables (39003 - // roles, 39004 AV participants) are durable group state alongside 39000/39001/39002, - // so they're stored replaceably. The 9xxx moderation actions and join/leave requests - // are regular one-shot events the relay is authoritative for (it applies them and - // republishes the 39000/39001/39002); we store them so they're queryable and don't - // fall through to the "Not Supported" warning, but we don't act on them client-side. - is SupportedRolesEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) + is GroupPinnedEvent -> { + consume(event, relay, wasVerified) } + // 39003 (relay-declared roles) is durable group state like 39000/39001/39002: + // route it onto the channel so a moderation UI can offer the relay's role set. + is SupportedRolesEvent -> { + consume(event, relay, wasVerified) + } + + // Remaining NIP-29 relay-group kinds. The relay-signed 39004 AV-participants + // addressable is durable group state, so it's stored replaceably. The 9xxx + // moderation actions and join/leave requests are regular one-shot events the + // relay is authoritative for (it applies them and republishes the + // 39000/39001/39002); we store them so they're queryable and don't fall through + // to the "Not Supported" warning, but we don't act on them client-side. is GroupParticipantsEvent -> { consumeBaseReplaceable(event, relay, wasVerified) } @@ -3922,6 +3987,10 @@ object LocalCache : ILocalCache, ICacheProvider { consumeRegularEvent(event, relay, wasVerified) } + is UpdatePinListEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + is DeleteGroupEvent -> { consumeRegularEvent(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt index d23ccb0d78..a2946aec11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -57,6 +58,7 @@ class HiddenUsersState( ): LiveHiddenUsers { val hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null } val hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null } + val hiddenHashtags = blockList.mapNotNullTo(mutableSetOf()) { if (it is HashtagTag) it.hashtag.lowercase() else null } + muteList.mapNotNull { if (it is HashtagTag) it.hashtag.lowercase() else null } val mutedThreads = muteList.mapNotNullTo(mutableSetOf()) { if (it is EventTag) it.eventId else null } return LiveHiddenUsers( @@ -67,6 +69,7 @@ class HiddenUsersState( hiddenUsers = hiddenUsers, spammers = transientHiddenUsers, hiddenWords = hiddenWords, + hiddenHashtags = hiddenHashtags, maxHashtagLimit = maxHashtagLimit, mutedThreads = mutedThreads, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt index ae90a7c09b..622335d2e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -142,6 +143,39 @@ class MuteListState( } } + suspend fun hideHashtag(hashtag: String): MuteListEvent { + val muteList = getMuteList() + + return if (muteList != null) { + MuteListEvent.add( + earlierVersion = muteList, + mute = HashtagTag(hashtag), + isPrivate = true, + signer = signer, + ) + } else { + MuteListEvent.create( + mute = HashtagTag(hashtag), + isPrivate = true, + signer = signer, + ) + } + } + + suspend fun showHashtag(hashtag: String): MuteListEvent? { + val muteList = getMuteList() + + return if (muteList != null) { + MuteListEvent.remove( + earlierVersion = muteList, + mute = HashtagTag(hashtag), + signer = signer, + ) + } else { + null + } + } + suspend fun hideThread(rootHex: HexKey): MuteListEvent { val muteList = getMuteList() return if (muteList != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt index b124ba8ca7..58c11797f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -22,14 +22,18 @@ package com.vitorpamplona.amethyst.service.notifications import android.content.Context import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch /** @@ -41,16 +45,23 @@ import kotlinx.coroutines.launch * L4 - BootCompletedReceiver (restart on boot) * L5 - ServiceWatchdogManager (AlarmManager, 5-min health check) * - * When enabled, all layers activate. When disabled, all layers deactivate. - * The manager watches the account's alwaysOnNotificationService setting - * and reacts to changes in real time. + * Two switches gate the system: * - * While enabled, every saved writable account is kept loaded in - * [AccountCacheState] so GiftWraps addressed to any of them (delivered via - * open relay subscriptions) get unwrapped by the owning account's - * `newNotesPreProcessor`. Without this, wraps for non-active accounts would - * sit in [com.vitorpamplona.amethyst.model.LocalCache] with no subscriber - * able to decrypt them. + * - The **global master** ([LocalPreferences.notificationServiceEnabledFlow], the + * "Background notification service" toggle / Quick Settings tile). When off, every + * layer is torn down and nothing restarts, regardless of any account's setting — + * this is the battery-saver "airplane mode". Persisted, so an explicit off survives + * restarts and crashes. + * - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService], + * "Keep this account active in the background"). While the master is on, the service + * runs as long as **at least one** writable account participates. + * + * While the master is on, every saved writable account is kept loaded in + * [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps + * addressed to any of them (delivered via open relay subscriptions) get unwrapped by + * the owning account's `newNotesPreProcessor`. Without this, wraps for non-active + * accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no + * subscriber able to decrypt them. */ class AlwaysOnNotificationServiceManager( private val context: Context, @@ -68,22 +79,52 @@ class AlwaysOnNotificationServiceManager( private var wasEnabled = false /** - * Starts watching the given account's always-on setting. - * When the setting changes, all layers are started or stopped accordingly. - * On initial load with false, nothing happens (no-op for users who never enabled it). + * Starts watching the global master switch and the participation flags of every + * loaded writable account. The service layers run while the master is on AND at + * least one account participates; the master overrides everything when off. + * + * Idempotent: safe to call again on account switch/login — it restarts the watch. */ - fun watchAccount(account: Account) { + @OptIn(ExperimentalCoroutinesApi::class) + fun start() { watchJob?.cancel() wasEnabled = false watchJob = scope.launch { - account.settings.alwaysOnNotificationService.collectLatest { enabled -> - if (enabled) { - wasEnabled = true - enableAllLayers() - } else if (wasEnabled) { - disableAllLayers() + localPreferences.notificationServiceEnabledFlow().collectLatest { masterEnabled -> + if (!masterEnabled) { + // Global airplane mode: suppress every layer regardless of + // per-account participation, and stop keeping accounts loaded. + if (wasEnabled) { + disableServiceLayers() + wasEnabled = false + } + stopMultiAccountPreload() + return@collectLatest } + + // Master on: keep every writable account loaded so its participation + // flag is observable and its gift wraps can decrypt, then run the + // service only while at least one account is participating. + startMultiAccountPreload() + accountsCache.accounts + .flatMapLatest { accounts -> + val flags = accounts.values.map { it.settings.alwaysOnNotificationService } + if (flags.isEmpty()) { + flowOf(false) + } else { + combine(flags) { values -> values.any { it } } + } + }.distinctUntilChanged() + .collectLatest { anyParticipating -> + if (anyParticipating) { + wasEnabled = true + enableServiceLayers() + } else if (wasEnabled) { + disableServiceLayers() + wasEnabled = false + } + } } } } @@ -93,10 +134,15 @@ class AlwaysOnNotificationServiceManager( watchJob = null preloadJob?.cancel() preloadJob = null + // Logout/terminate: tear the layers down explicitly. Otherwise the watchdog alarm + // and periodic worker stay scheduled and would resurrect the service for a + // logged-out user (nobody participating). + disableServiceLayers() + wasEnabled = false } - private fun enableAllLayers() { - Log.d(TAG, "Enabling all notification service layers") + private fun enableServiceLayers() { + Log.d(TAG, "Enabling notification service layers") // L1: Start foreground service NotificationRelayService.start(context) @@ -108,12 +154,10 @@ class AlwaysOnNotificationServiceManager( ServiceWatchdogManager.schedule(context) // L2 (FCM) and L4 (BOOT_COMPLETED) are always active via manifest - - startMultiAccountPreload() } - private fun disableAllLayers() { - Log.d(TAG, "Disabling all notification service layers") + private fun disableServiceLayers() { + Log.d(TAG, "Disabling notification service layers") // L1: Stop foreground service NotificationRelayService.stop(context) @@ -123,8 +167,6 @@ class AlwaysOnNotificationServiceManager( // L5: Cancel watchdog alarm ServiceWatchdogManager.cancel(context) - - stopMultiAccountPreload() } /** @@ -154,7 +196,7 @@ class AlwaysOnNotificationServiceManager( /** * Cancels the preload collector and releases every cached account except the - * currently active one, so users with the setting off return to single-account + * currently active one, so users with the master off return to single-account * memory/battery footprint. */ private fun stopMultiAccountPreload() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index c255ea4b36..2f48e5466b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -37,6 +37,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.pluralStringRes @@ -117,13 +118,18 @@ class NotificationRelayService : Service() { context.stopService(Intent(context, NotificationRelayService::class.java)) } + // Gate for the restart layers (watchdog, boot, catch-up worker, onResume): the + // global master must be on, and — matching the manager's rule — at least one loaded + // writable account must be participating. An empty cache (logged out, or accounts + // not yet loaded) gates OFF, so the layers never resurrect the service for a + // logged-out user; on cold boot the account loads headlessly and the manager starts + // the service itself once a participant exists. fun isEnabled(context: Context): Boolean = try { - Amethyst.instance.sessionManager - .loggedInAccount() - ?.settings - ?.alwaysOnNotificationService - ?.value == true + LocalPreferences.isNotificationServiceEnabled() && + Amethyst.instance.accountsCache.accounts.value.values.any { + it.settings.alwaysOnNotificationService.value + } } catch (e: Exception) { false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt new file mode 100644 index 0000000000..7c9b4ea907 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt @@ -0,0 +1,141 @@ +/* + * 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.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Quick Settings tile that temporarily enables or disables the always-on background + * notification service — a battery-saver "airplane mode" — without digging through + * Settings. + * + * The tile toggles the **global master** switch ([LocalPreferences.setNotificationServiceEnabled]), + * not any per-account setting: when off, [AlwaysOnNotificationServiceManager] tears down + * every layer for all accounts; when on, each account's own "keep active in the + * background" flag decides who participates. The master is persisted, so an explicit off + * survives restarts and crashes. + * + * The switch is global, so the tile does not depend on a logged-in account. It only + * needs `Amethyst.instance` (main process) to reach [LocalPreferences]; during the brief + * cold-start window before that is built — or in the `:napplet` process, which never + * hosts this tile — it renders as unavailable and the next `onStartListening` recovers. + */ +class NotificationServiceTileService : TileService() { + companion object { + private const val TAG = "NotifServiceTile" + } + + private var scope: CoroutineScope? = null + private var watchJob: Job? = null + + /** True once `Amethyst.instance` (and therefore [LocalPreferences]) is reachable. */ + private fun preferencesReady(): Boolean = + try { + Amethyst.instance + true + } catch (e: Exception) { + false + } + + override fun onStartListening() { + super.onStartListening() + // onStartListening/onStopListening are balanced by the framework, but cancel + // any stale collector defensively so a re-entrant start can't leak a scope. + watchJob?.cancel() + scope?.cancel() + + refreshTile() + if (!preferencesReady()) return + + val newScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + scope = newScope + // Keep the tile in sync when the master is flipped elsewhere (the Settings + // toggle) while the shade is open. + watchJob = + newScope.launch { + LocalPreferences.notificationServiceEnabledFlow().collectLatest { + refreshTile() + } + } + } + + override fun onStopListening() { + watchJob?.cancel() + watchJob = null + scope?.cancel() + scope = null + super.onStopListening() + } + + override fun onClick() { + super.onClick() + if (!preferencesReady()) { + Log.w(TAG, "Preferences not ready; ignoring tile click") + refreshTile() + return + } + LocalPreferences.setNotificationServiceEnabled(!LocalPreferences.isNotificationServiceEnabled()) + refreshTile() + } + + private fun refreshTile() { + val tile = qsTile ?: return + + if (!preferencesReady()) { + tile.state = Tile.STATE_UNAVAILABLE + } else { + val enabled = LocalPreferences.isNotificationServiceEnabled() + tile.state = if (enabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + tile.subtitleCompat( + getString( + if (enabled) { + R.string.always_on_notif_tile_subtitle_on + } else { + R.string.always_on_notif_tile_subtitle_off + }, + ), + ) + } + + // Label and icon come from the manifest (), + // which is the tile's default — no need to reset them on every refresh. + tile.updateTile() + } + + private fun Tile.subtitleCompat(text: CharSequence) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + subtitle = text + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 82a1de7a66..9dbcb3cceb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -396,6 +396,33 @@ fun observeUserIsFollowingHashtag( return flow.collectAsStateWithLifecycle(hashtag in accountViewModel.account.hashtagList.flow.value) } +@SuppressLint("StateFlowValueCalledInComposition") +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserIsMutingHashtag( + hashtag: String, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(accountViewModel, hashtag) { + accountViewModel.account.hiddenUsers.flow + .mapLatest { it.isHashtagHidden(hashtag) } + .onStart { + emit( + accountViewModel.account.hiddenUsers.flow.value + .isHashtagHidden(hashtag), + ) + }.distinctUntilChanged() + .flowOn(Dispatchers.IO) + } + + return flow.collectAsStateWithLifecycle( + accountViewModel.account.hiddenUsers.flow.value + .isHashtagHidden(hashtag), + ) +} + @SuppressLint("StateFlowValueCalledInComposition") @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index d87ea9db23..30fa7e80c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink +import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent @@ -226,7 +227,8 @@ fun uriToRoute( relayGroupInviteRoute(uri)?.let { return it } concordInviteRoute(uri)?.let { return it } - val nip19 = Nip19Parser.uriToRoute(uri)?.entity + val parsedNip19 = Nip19Parser.uriToRoute(uri) + val nip19 = parsedNip19?.entity if (nip19 != null) { LocalCache.consume(nip19) @@ -252,7 +254,7 @@ fun uriToRoute( } is NAddress -> { - relayGroupDirectRoute(nip19) + relayGroupDirectRoute(nip19, parsedNip19.additionalChars) ?: routeFor( note = LocalCache.getOrCreateAddressableNote(nip19.address()), loggedIn = account, @@ -341,10 +343,16 @@ private fun calendarDirectRoute(nip19: NAddress): Route? = * id alone can't be resolved. With a hint we open the group chat straight away * (cold start included); without one there's nowhere to look, so fall through. */ -private fun relayGroupDirectRoute(nip19: NAddress): Route? { +private fun relayGroupDirectRoute( + nip19: NAddress, + additionalChars: String?, +): Route? { if (nip19.kind != GroupMetadataEvent.KIND) return null val relay = nip19.relay.firstOrNull() ?: return null - return Route.RelayGroup(nip19.dTag, relay.url) + // The spec allows an invite code appended as `naddr1…?invite=`; it arrives + // in the trailing chars after the bech32 body. Carry it so the group auto-joins. + val inviteCode = GroupNAddrInvite.parse(additionalChars) + return Route.RelayGroup(nip19.dTag, relay.url, inviteCode = inviteCode) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 32780c6599..a0e80679bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNIP19 +import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import kotlinx.collections.immutable.ImmutableList @@ -212,10 +213,13 @@ private fun DisplayAddress( // only per relay), so we need that hint to open it. val groupRelay = if (nip19.kind == GroupMetadataEvent.KIND) nip19.relay.firstOrNull() else null if (groupRelay != null) { + // An invite code may be appended to the group naddr as `?invite=`; it lands + // in additionalChars. Pass it through so the group auto-joins with a kind-9021 code. + val inviteCode = GroupNAddrInvite.parse(additionalChars) CreateClickableText( clickablePart = "#${nip19.dTag}", suffix = additionalChars, - route = Route.RelayGroup(nip19.dTag, groupRelay.url), + route = Route.RelayGroup(nip19.dTag, groupRelay.url, inviteCode = inviteCode), nav = nav, ) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt index 9007e1182a..d543f10d57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.flow.MutableStateFlow /** * The resolved presentation of a pinned chat/group [BottomBarEntry] — enough to render its avatar in @@ -153,12 +154,8 @@ fun rememberConcordEntryDisplay( val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle() val session = remember(entry.communityId, revision) { account.concordSessions.sessionFor(entry.communityId) } - val metadata = - session - ?.state - ?.value - .takeIf { revision >= 0 } - ?.metadata + val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() + val metadata = state.takeIf { revision >= 0 }?.metadata val fallbackName = remember(communities, entry.communityId) { communities.firstOrNull { it.id == entry.communityId }?.name?.ifBlank { null } } val label = metadata?.name?.takeIf { it.isNotBlank() } ?: fallbackName ?: stringRes(R.string.concord_home_title) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 29cbeafdb7..143ba838c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1619,6 +1619,16 @@ class AccountViewModel( body: String, ) = launchSigner { account.postRelayGroupThread(channel, title, body) } + fun pinRelayGroupMessage( + channel: RelayGroupChannel, + note: Note, + ) = launchSigner { account.pinRelayGroupMessage(channel, note.idHex) } + + fun unpinRelayGroupMessage( + channel: RelayGroupChannel, + note: Note, + ) = launchSigner { account.unpinRelayGroupMessage(channel, note.idHex) } + fun removeRelayGroupUser( channel: RelayGroupChannel, pubkey: HexKey, @@ -1684,6 +1694,10 @@ class AccountViewModel( fun hideWord(word: String) = launchSigner { account.hideWord(word) } + fun hideHashtag(tag: String) = launchSigner { account.hideHashtag(tag) } + + fun showHashtag(tag: String) = launchSigner { account.showHashtag(tag) } + fun isLoggedUser(pubkeyHex: HexKey?): Boolean = account.signer.pubKey == pubkeyHex fun isLoggedUser(user: User?): Boolean = isLoggedUser(user?.pubkeyHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index f3b7d11a4c..d00d3120de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -74,6 +75,11 @@ fun RefreshingChatroomFeedView( // LazyColumn), so a caller (private DMs) can drive demand-driven paging off viewport visibility // rather than per-row composition. No-op for callers that don't paginate. sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, + // Optional external jump request (e.g. a "pinned message" bar): when it holds a note id, the feed + // scrolls to that message and highlights it, then calls [onJumpHandled] to clear it. Null for + // callers with no external jump affordance. + jumpToNoteId: State? = null, + onJumpHandled: () -> Unit = {}, ) { SaveableFeedState(feedContentState, scrollStateKey) { listState -> listStateObserver(listState) @@ -89,6 +95,8 @@ fun RefreshingChatroomFeedView( olderBoundary, markersInGap, sentinels, + jumpToNoteId, + onJumpHandled, ) } } @@ -106,6 +114,8 @@ fun RenderChatFeedView( olderBoundary: (@Composable () -> Unit)? = null, markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, + jumpToNoteId: State? = null, + onJumpHandled: () -> Unit = {}, ) { val feedState by feed.feedContent.collectAsStateWithLifecycle() @@ -136,6 +146,8 @@ fun RenderChatFeedView( olderBoundary, markersInGap, sentinels, + jumpToNoteId, + onJumpHandled, ) } } @@ -155,6 +167,8 @@ fun ChatFeedLoaded( olderBoundary: (@Composable () -> Unit)? = null, markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, + jumpToNoteId: State? = null, + onJumpHandled: () -> Unit = {}, ) { val items by loaded.feed.collectAsStateWithLifecycle() @@ -180,6 +194,21 @@ fun ChatFeedLoaded( } } + // External jump request (pinned-message bar). Keyed on the id alone, so a message arriving mid-jump + // can't cancel the scroll animation or restart the effect. Always clears the request after one + // attempt — even when the target isn't loaded — so it never sticks and a repeat tap fires again. + val jumpId = jumpToNoteId?.value + LaunchedEffect(jumpId) { + if (jumpId != null) { + val index = items.list.indexOfFirst { it.idHex == jumpId } + if (index >= 0) { + listState.animateScrollToItem(index) + highlightedNoteId.value = jumpId + } + onJumpHandled() + } + } + LazyColumn( contentPadding = FeedPadding, modifier = Modifier.fillMaxSize(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 5a7bb9155b..081a7925f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -59,9 +59,11 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -323,6 +325,13 @@ fun ChatMessageActionSheet( } } } + + // NIP-29 moderator action: pin/unpin this message in its relay group. + // Only rendered (and only subscribes) when the note belongs to a group. + val relayGroup = remember(note) { note.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel } } + if (relayGroup != null && !note.isDraft()) { + RelayGroupPinTile(note, relayGroup, onDismiss, accountViewModel) + } } } @@ -331,6 +340,43 @@ fun ChatMessageActionSheet( } } +/** + * NIP-29 moderator-only tile that pins/unpins this message in its relay group. + * Observes the group's live roster + pin list, so it shows the right verb and + * hides itself entirely for members who can't moderate. Pinning replaces the whole + * kind-9010 list; the relay republishes the kind-39005 pins the bar reads. + */ +@Composable +private fun RelayGroupPinTile( + note: Note, + baseChannel: RelayGroupChannel, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, +) { + val channelState by observeChannel(baseChannel, accountViewModel) + val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel + + val canModerate = channel.membershipOf(accountViewModel.userProfile().pubkeyHex).canModerate() + if (!canModerate) return + + val pinned = channel.isPinned(note.idHex) + + SectionDivider() + TileRow { + if (pinned) { + ActionTile(MaterialSymbols.PushPin, stringRes(R.string.relay_group_unpin_message)) { + accountViewModel.unpinRelayGroupMessage(channel, note) + onDismiss() + } + } else { + ActionTile(MaterialSymbols.PushPin, stringRes(R.string.relay_group_pin_message)) { + accountViewModel.pinRelayGroupMessage(channel, note) + onDismiss() + } + } + } +} + /** * The expand/collapse control between the always-visible quick actions and the full * action inventory. Keeps the sheet short on open (the common case is a reaction, a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 3ad0f20501..bbe54efd45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -252,6 +252,12 @@ fun NormalChatNote( // carrying per-message metadata (expiration, geohash, PoW, legacy-DM marker). val footerHasMeta = remember(note.event) { chatFooterHasMeta(note) } + // Only mount the reaction/zap chip row when the message actually has engagement. + // The chips overlap the bubble's bottom edge, so the bubble reserves space beneath + // the last line of text for them — without this gate every footerless bubble would + // reserve that space for an empty row, leaving a dead gap under the text. + val hasEngagement = !innerQuote && hasChatEngagement(note, accountViewModel) + ChatBubbleLayout( isLoggedInUser = isLoggedInUser, isDraft = note.event is DraftWrapEvent, @@ -299,7 +305,7 @@ fun NormalChatNote( ) }, reactionsRow = - if (!innerQuote) { + if (hasEngagement) { { ChatReactionChips(note, accountViewModel, nav) } } else { null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt index d4e9662e79..c20249e916 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCorner import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteMinichatReplyCount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -89,6 +90,43 @@ private data class ReactionChip( private val ChipGlyphFontSize = 13.sp +/** + * Whether [ChatReactionChips] would render anything for this message — a reaction, a + * zap, a pending zap, or a minichat-reply count. Mirrors the early-return gate in + * [RenderChatReactionChips] and keeps the same live subscriptions, so a message that + * gains its first reaction still flips this on. The bubble uses it to decide whether to + * even mount the chip row (and reserve overlap space beneath the text) instead of always + * paying for an empty row on every message. + */ +@Composable +fun hasChatEngagement( + baseNote: Note, + accountViewModel: AccountViewModel, +): Boolean { + val reactionsState by observeNoteReactions(baseNote, accountViewModel) + val hasReactions by + remember(reactionsState) { + derivedStateOf { (reactionsState?.note ?: baseNote).reactions.isNotEmpty() } + } + + val zapsState by observeNoteZaps(baseNote, accountViewModel) + val hasZaps by + remember(zapsState) { + derivedStateOf { (zapsState?.note ?: baseNote).zaps.isNotEmpty() } + } + + val supportsMinichat = + remember(baseNote) { + baseNote.inGatherers?.any { it is ConcordChannel || it is PublicChatChannel || it is RelayGroupChannel } == true + } + val minichatCount by if (supportsMinichat) observeNoteMinichatReplyCount(baseNote, accountViewModel) else remember { mutableStateOf(0) } + + val zapsInFlight by accountViewModel.zapsInFlightFlow.collectAsStateWithLifecycle() + val isZapping = zapsInFlight.contains(baseNote.idHex) + + return hasReactions || hasZaps || minichatCount > 0 || isZapping +} + /** * Messenger-style pills under a chat bubble summarizing who engaged with the message: * one chip per reaction emoji (with count, highlighted when the logged-in user is diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt index cddb259cd9..1c97a4d7fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation @@ -78,9 +79,13 @@ fun chatFooterHasMeta(note: Note): Boolean { return event is PrivateDmEvent || event.expiration() != null || event.geoHashOrScope() != null || - event.strongPoWOrNull() != null + event.strongPoWOrNull() != null || + note.isPinnedInRelayGroup() } +/** True when this note's NIP-29 group has it in its pinned list (kind 39005). */ +fun Note.isPinnedInRelayGroup(): Boolean = inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }?.isPinned(idHex) == true + /** * The small row at the bottom of a chat bubble: inline status glyphs (legacy-DM, * expiration, geohash, proof-of-work — each shown only when present) followed, on the @@ -102,6 +107,7 @@ fun ChatMessageFooter( Row(verticalAlignment = Alignment.CenterVertically) { // Each glyph self-gates and renders nothing when not applicable. + ChatPinnedBadge(note) IncognitoBadge(note) ChatExpiration(note) @@ -115,7 +121,7 @@ fun ChatMessageFooter( } if (showTime) { - val hasGlyph = event is PrivateDmEvent || event?.expiration() != null || geo != null || pow != null + val hasGlyph = note.isPinnedInRelayGroup() || event is PrivateDmEvent || event?.expiration() != null || geo != null || pow != null if (hasGlyph) Spacer(StdHorzSpacer) // Drafts aren't published, so no relay/delivery detail; everything else gets @@ -129,6 +135,19 @@ fun ChatMessageFooter( } } +/** A small pin glyph on a bubble whose message is pinned in its NIP-29 group. */ +@Composable +fun ChatPinnedBadge(note: Note) { + if (!note.isPinnedInRelayGroup()) return + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = stringRes(R.string.relay_group_pinned_content_description), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(StdHorzSpacer) +} + @Composable fun ChatExpiration(note: Note) { val event = note.event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 84121fe740..3aebe57917 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -47,7 +47,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -56,20 +58,31 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight 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.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -94,6 +107,21 @@ fun ConcordChannelListScreen( val state by (session?.state ?: remember { kotlinx.coroutines.flow.MutableStateFlow(null) }) .collectAsStateWithLifecycle() + // Live typing heartbeats (kind 23311) per channel, so a channel where someone is composing shows + // "typing…" in place of its last-message preview. A single screen-level ticker re-applies the + // freshness window for every row at once, and only spins while at least one heartbeat is live. + val typingMap by (session?.typing ?: remember { kotlinx.coroutines.flow.MutableStateFlow(emptyMap>()) }) + .collectAsStateWithLifecycle() + var typingNow by remember { mutableLongStateOf(TimeUtils.now()) } + LaunchedEffect(typingMap) { + if (typingMap.values.all { it.isEmpty() }) return@LaunchedEffect + while (true) { + typingNow = TimeUtils.now() + if (typingMap.values.all { per -> per.values.none { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS } }) break + delay(2000L) + } + } + val scope = rememberCoroutineScope() var inviteLink by remember { mutableStateOf(null) } var minting by remember { mutableStateOf(false) } @@ -254,28 +282,26 @@ fun ConcordChannelListScreen( def.private == true -> MaterialSymbols.Lock else -> MaterialSymbols.Tag } - Row( - Modifier - .fillMaxWidth() - .clickable { nav.nav(Route.Concord(communityId, entry.key)) } - .padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = if (canManageChannels) 4.dp else 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - SymbolIcon( - symbol = icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text(name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1) - if (canManageChannels) { - ConcordChannelRowMenu( - onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, - onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, - ) + val typingAuthors = + remember(typingMap, typingNow, entry.key) { + (typingMap[entry.key] ?: emptyMap()) + .filterValues { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS } + .keys + .sorted() } - } + ConcordChannelListRow( + communityId = communityId, + channelKey = entry.key, + channelName = name, + icon = icon, + isVoice = def.voice == true, + typingAuthors = typingAuthors, + canManageChannels = canManageChannels, + accountViewModel = accountViewModel, + onClick = { nav.nav(Route.Concord(communityId, entry.key)) }, + onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, + onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, + ) HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } @@ -283,6 +309,169 @@ fun ConcordChannelListScreen( } } +/** + * One channel row in the community's server view: its icon, name, a preview of the last message + * (author + snippet), the relative time of that message, and an unread-message count badge — plus + * the manager-only overflow menu. Name/icon come from the folded Control Plane definition; the + * preview, time, and unread count are read reactively from the channel's own message store so they + * fill in the moment messages fold in and clear as soon as the channel is opened (last-read advances). + */ +@Composable +private fun ConcordChannelListRow( + communityId: String, + channelKey: String, + channelName: String, + icon: MaterialSymbol, + isVoice: Boolean, + typingAuthors: List, + canManageChannels: Boolean, + accountViewModel: AccountViewModel, + onClick: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + val account = accountViewModel.account + // getOrCreate (not getIfExists): a channel folded on the Control Plane may have no message note + // yet; its notes flow then makes the preview/time/unread/facepile reactive as messages arrive. + val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) } + val channelState by channel + .flow() + .notes.stateFlow + .collectAsStateWithLifecycle() + // The newest *timeline* message (not the raw lastNote): skips kind-1111 thread replies and + // hidden authors so the preview + time match the channel feed and the unread badge below. + val lastNote = remember(channelState) { channel.newestTimelineNote(account) } + val unread by + remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) } + .collectAsStateWithLifecycle(0) + val hasUnread = unread > 0 + // The recent posters' faces — recomputed as the channel's notes change (keyed on channelState). + val faceAuthors = remember(channelState) { channel.recentAuthorHexes(FACEPILE_MAX) } + + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = if (canManageChannels) 4.dp else 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SymbolIcon( + symbol = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (hasUnread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + // Line 1: channel name + the recent-posters facepile pushed to the right. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + channelName, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ConcordAuthorFacepile(faceAuthors, accountViewModel) + } + // Line 2: the last-message preview (or a live "typing…"), then the time + unread badge. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.weight(1f)) { + ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel) + } + lastNote?.createdAt()?.let { ts -> + Text( + timeAgo(ts, LocalContext.current, prefix = ""), + style = MaterialTheme.typography.labelSmall, + color = if (hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + ConcordUnreadBadge(unread) + } + } + if (canManageChannels) { + ConcordChannelRowMenu( + onRename = onRename, + onDelete = onDelete, + ) + } + } +} + +/** How many recent-poster avatars a channel row's facepile shows at most. */ +private const val FACEPILE_MAX = 4 + +/** + * The line under a channel name. When someone is composing it shows a live italic "X is typing…"; + * otherwise the last message's author + a snippet ("author: hello"), or a muted "No messages yet" + * placeholder before anything has folded in. Author names resolve reactively (hex → profile name). + */ +@Composable +private fun ConcordChannelPreviewLine( + lastNote: Note?, + isVoice: Boolean, + typingAuthors: List, + accountViewModel: AccountViewModel, +) { + if (typingAuthors.isNotEmpty()) { + val label = + when (typingAuthors.size) { + 1 -> stringRes(com.vitorpamplona.amethyst.R.string.concord_typing_one, rememberConcordDisplayName(typingAuthors[0], accountViewModel)) + 2 -> + stringRes( + com.vitorpamplona.amethyst.R.string.concord_typing_two, + rememberConcordDisplayName(typingAuthors[0], accountViewModel), + rememberConcordDisplayName(typingAuthors[1], accountViewModel), + ) + else -> stringRes(com.vitorpamplona.amethyst.R.string.concord_typing_many) + } + Text( + label, + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + return + } + + val event = lastNote?.event + val author = lastNote?.author + val preview: String = + if (event != null && author != null) { + val authorName by observeUserName(author, accountViewModel) + val body = event.content.take(80) + if (body.isBlank()) authorName else "$authorName: $body" + } else if (event != null) { + event.content.take(80) + } else { + // Voice channels never carry chat notes, so "No messages yet" would read oddly — leave blank. + if (isVoice) return + stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_no_messages) + } + Text( + preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */ +@Composable +private fun rememberConcordDisplayName( + hex: HexKey, + accountViewModel: AccountViewModel, +): String { + val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) } + val name by observeUserName(user, accountViewModel) + return name +} + /** A pending channel create ([channelIdHex] null) or rename target. */ private data class ConcordChannelEditor( val channelIdHex: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordFacepile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordFacepile.kt new file mode 100644 index 0000000000..328458adc2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordFacepile.kt @@ -0,0 +1,84 @@ +/* + * 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.chats.publicChannels.concord + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor +import com.vitorpamplona.amethyst.ui.note.ObserveAndDrawInnerUserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A horizontal stack of overlapping avatars for the recent posters in a channel — the "who's here" + * cue that makes a busy channel feel alive. Each avatar wears a thin ring in the row's background + * colour so the overlap reads as a deck of cards; the newest poster sits on top (leftmost, highest + * z-index). Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally. + */ +@Composable +fun ConcordAuthorFacepile( + authorHexes: List, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + avatarSize: Dp = 22.dp, + maxShown: Int = 4, +) { + if (authorHexes.isEmpty()) return + val shown = authorHexes.take(maxShown) + // A ring one-and-a-half dp wide, and each avatar pulled left over its neighbour by a third of + // its width — enough overlap to read as a stack without hiding faces. + val ring = 1.5.dp + val overlap = avatarSize * 0.36f + Row(modifier, horizontalArrangement = Arrangement.spacedBy(-overlap)) { + shown.forEachIndexed { index, hex -> + Box( + Modifier + // Newest (index 0) on top so it isn't clipped by the one after it. + .zIndex((shown.size - index).toFloat()) + .size(avatarSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .padding(ring), + ) { + LoadUser(baseUserHex = hex, accountViewModel) { user -> + if (user != null) { + ObserveAndDrawInnerUserPicture(user, avatarSize - ring * 2, accountViewModel) + } else { + DisplayBlankAuthor(avatarSize - ring * 2, Modifier, accountViewModel) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index a368f2429c..6211adcf9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -258,10 +257,9 @@ private fun communityActivity( } ?: 0L /** - * The number of a community's channels with a message newer than this account last read there — - * combines each channel's persisted last-read ([concordChannelLastReadRoute]) against its - * [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel.lastNote]. Recomputed on - * [revision] so a freshly-folded message flips the badge. + * The total number of new messages across a community's channels — the sum, over every channel, of + * messages newer than this account last read that channel ([concordChannelUnreadCountFlow]). + * Recomputed reactively as messages fold in and as channels are opened (last-read advances). */ @Composable private fun communityUnreadCount( @@ -271,23 +269,14 @@ private fun communityUnreadCount( ): Int { if (channelKeys.isEmpty()) return 0 // Keyed only on the channel set (not the global revision): each per-channel flow reacts to both - // its last-read marker AND the channel's own notes flow, so a folded message flips the badge + // its last-read marker AND the channel's own notes flow, so a folded message updates the badge // without tearing down and restarting every flow on every unrelated fold (which reset the badge // to 0 and made it flicker). val flow = remember(communityId, channelKeys) { combine( - channelKeys.map { key -> - val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, key)) - combine( - account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)), - channel.flow().notes.stateFlow, - ) { lastRead, state -> - val last = state.channel.lastNote?.createdAt() ?: 0L - if (last > lastRead) 1 else 0 - } - }, - ) { flags -> flags.sum() } + channelKeys.map { key -> concordChannelUnreadCountFlow(account, communityId, key) }, + ) { counts -> counts.sum() } } return flow.collectAsStateWithLifecycle(0).value } @@ -357,7 +346,7 @@ private fun CommunityHeader( ) } } - if (unread > 0) UnreadBadge(unread) + ConcordUnreadBadge(unread) SymbolIcon( // ▲ only when fully open; ▼ for both CLOSED and the UNREAD peek ("more to reveal"). symbol = if (mode == ChannelExpand.OPEN) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, @@ -388,25 +377,6 @@ private fun CommunityBanner( ) } -/** A small pill showing the unread-channel count next to a community. */ -@Composable -private fun UnreadBadge(count: Int) { - Box( - modifier = - Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - .padding(horizontal = 7.dp, vertical = 2.dp), - contentAlignment = Alignment.Center, - ) { - Text( - count.toString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary, - ) - } -} - /** * One channel row with its last message (author + snippet), relative time, and an unread marker — * bold + a dot when there's a message newer than this account last read the channel. @@ -432,9 +402,13 @@ private fun ConcordChannelRow( .flow() .notes.stateFlow .collectAsStateWithLifecycle() - val lastNote = channelState.channel.lastNote - val lastReadTime by account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)).collectAsStateWithLifecycle() - val unread = (lastNote?.createdAt() ?: Long.MIN_VALUE) > lastReadTime + // The newest *timeline* message (not the raw lastNote): skips kind-1111 thread replies and + // hidden authors so the preview + time match the channel feed and the unread badge below. + val lastNote = remember(channelState) { channel.newestTimelineNote(account) } + val unreadCount by + remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) } + .collectAsStateWithLifecycle(0) + val unread = unreadCount > 0 // In the UNREAD peek, a read channel simply isn't shown. if (hideIfRead && !unread) return @@ -486,12 +460,10 @@ private fun ConcordChannelRow( Text( timeAgo(ts, LocalContext.current, prefix = ""), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = if (unread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) } - if (unread) { - Box(Modifier.size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) - } + ConcordUnreadBadge(unreadCount) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index 31c400395f..8c592fb7e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.ConcordInviteResult import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -50,7 +51,15 @@ private sealed interface RedeemState { val communityId: String, ) : RedeemState - data object Failed : RedeemState + /** + * The redeem failed. [messageRes] explains why; [canRetry] is true only for a + * transient miss — an invalid or incompatible link can never succeed, so we don't + * offer a retry that would just loop back onto the same spinner. + */ + data class Failed( + val messageRes: Int, + val canRetry: Boolean, + ) : RedeemState } /** @@ -69,8 +78,18 @@ fun ConcordInviteScreen( LaunchedEffect(link, state) { if (state is RedeemState.Working) { - val communityId = accountViewModel.account.joinConcordViaInvite(link) - state = if (communityId != null) RedeemState.Done(communityId) else RedeemState.Failed + state = + when (val result = accountViewModel.account.joinConcordViaInvite(link)) { + is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId) + is ConcordInviteResult.InvalidLink -> + RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_invalid, canRetry = false) + is ConcordInviteResult.Incompatible -> + RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_incompatible, canRetry = false) + is ConcordInviteResult.Revoked -> + RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_revoked, canRetry = false) + is ConcordInviteResult.NotReachable -> + RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed, canRetry = true) + } } } @@ -96,16 +115,19 @@ fun ConcordInviteScreen( } is RedeemState.Failed -> { + val failed = state as RedeemState.Failed Text( - stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_failed), + stringRes(failed.messageRes), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, ) - Button( - onClick = { state = RedeemState.Working }, - modifier = Modifier.padding(top = 16.dp), - ) { - Text(stringRes(com.vitorpamplona.amethyst.R.string.retry)) + if (failed.canRetry) { + Button( + onClick = { state = RedeemState.Working }, + modifier = Modifier.padding(top = 16.dp), + ) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.retry)) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt new file mode 100644 index 0000000000..f7590d71c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt @@ -0,0 +1,117 @@ +/* + * 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.chats.publicChannels.concord + +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +/** + * A reactive flow of how many messages in [communityId]/[channelKey] are newer than the + * timestamp this account last read that channel. It combines the persisted last-read marker + * ([concordChannelLastReadRoute]) with the channel's own notes flow, so the count updates both + * when a fresh message folds in and when the user opens the channel (which advances last-read). + * + * Uses `getOrCreate` (not `getIfExists`): a channel folded on the Control Plane may have no + * message-buffer note yet, and the flow must still exist so the badge appears the moment its + * first message lands. Counting straight off [ConcordChannel.notes] ignores the synthetic + * placeholder note (it is never added to that cache) and any note with no timestamp. + */ +fun concordChannelUnreadCountFlow( + account: Account, + communityId: String, + channelKey: String, +): Flow { + val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) + // The notes flow drives recomputation (each new message re-emits its channel state); the count + // itself reads the channel's own note store, so it survives the base-typed ChannelState.channel. + return combine( + account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)), + channel.flow().notes.stateFlow, + ) { lastRead, _ -> + channel.newMessagesSince(account, lastRead) + } +} + +/** + * True for a note the Concord channel *timeline* actually renders — the same predicate as + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedFilter]'s + * `isTimelineMessage`: a loaded, acceptable message that is **not** a kind-1111 [CommentEvent]. + * + * A [CommentEvent] is a *minichat thread reply* that lives inside its parent's thread, not on the + * flat timeline, so it never composes on the channel screen and never advances the last-read + * marker. Every list-row surface that summarizes a channel — the unread badge + * ([newMessagesSince]), the last-message preview + timestamp ([newestTimelineNote]), and the + * Messages hub row — reuses this so none of them can disagree with the open channel's feed: + * a trailing comment can't stick the badge at a count the user can never clear, nor show up as a + * "last message" that isn't in the timeline. Unacceptable (muted/blocked) authors are hidden for + * the same reason. + */ +fun isConcordTimelineMessage( + note: Note, + account: Account, +): Boolean = note.event.let { it != null && it !is CommentEvent } && account.isAcceptable(note) + +/** + * The newest timeline message in this channel (see [isConcordTimelineMessage]), or null if none — + * the note the list/hub rows show as the channel's "last message". Unlike [ConcordChannel.lastNote] + * (the raw newest note of any kind), this skips thread replies and hidden authors so the preview + * matches what the channel feed renders and the unread badge counts. + */ +fun ConcordChannel.newestTimelineNote(account: Account): Note? = + notes + .filter { _, note -> isConcordTimelineMessage(note, account) } + .minWithOrNull(Channel.DefaultFeedOrder) + +/** The number of this channel's timeline messages created strictly after [sinceSecs] (0 if none). */ +private fun ConcordChannel.newMessagesSince( + account: Account, + sinceSecs: Long, +): Int = + notes.count { _, note -> + (note.createdAt() ?: 0L) > sinceSecs && isConcordTimelineMessage(note, account) + } + +/** + * The pubkeys of the [limit] most-recent distinct posters in this channel, newest first — the + * facepile shown on a channel row. One O(notes) pass keeps each author's latest post time, so a + * chatty author counts once (at their newest message) rather than crowding out quieter voices. + */ +fun ConcordChannel.recentAuthorHexes(limit: Int): List { + val latestByAuthor = HashMap() + for (note in notes.values()) { + val author = note.author?.pubkeyHex ?: continue + val at = note.createdAt() ?: continue + val prev = latestByAuthor[author] + if (prev == null || at > prev) latestByAuthor[author] = at + } + return latestByAuthor.entries + .sortedByDescending { it.value } + .take(limit) + .map { it.key } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt new file mode 100644 index 0000000000..7b321b32af --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt @@ -0,0 +1,78 @@ +/* + * 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.chats.publicChannels.concord + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R + +/** Counts above this render as "N+" so a very busy channel doesn't blow out the row. */ +private const val CONCORD_UNREAD_CAP = 99 + +/** + * A small pill showing an unread-message [count] (new messages since this account last read). + * Renders nothing when [count] is zero, so callers can place it unconditionally. Capped at + * [CONCORD_UNREAD_CAP]+ and carries a plural content description for screen readers. + */ +@Composable +fun ConcordUnreadBadge( + count: Int, + modifier: Modifier = Modifier, +) { + if (count <= 0) return + val label = if (count > CONCORD_UNREAD_CAP) "$CONCORD_UNREAD_CAP+" else count.toString() + val description = pluralStringResource(R.plurals.concord_unread_messages, count, count) + Box( + modifier = + modifier + .semantics { contentDescription = description } + // Smoothly grows/shrinks as the count changes digits (1 → 2 → … → 99+) instead of + // snapping — a small touch that makes new activity feel noticed. + .animateContentSize() + .sizeIn(minWidth = 20.dp, minHeight = 20.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt index 7c748382b6..e0154c8373 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -58,4 +59,18 @@ fun ChannelFilterAssemblerSubscription( dataSource.invalidateFilters() } } + + // Relay groups: when the kind-39005 pin list changes, re-invalidate so the id-based back-fill + // for pinned message bodies (see filterMetadataToRelayGroup) picks up the new ids. Keyed on the + // pin list alone, so unrelated roster/metadata churn doesn't force a re-subscribe. + if (channel is RelayGroupChannel) { + val metadataState by channel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val pinnedIds = (metadataState.channel as? RelayGroupChannel)?.pinnedEventIds ?: channel.pinnedEventIds + LaunchedEffect(pinnedIds) { + dataSource.invalidateFilters() + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt index a9c208ca76..005b066e1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt @@ -27,15 +27,17 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent -/** Relay-signed group directory kinds: metadata + admins + members + roles. */ +/** Relay-signed group directory kinds: metadata + admins + members + roles + pins. */ private val RELAY_GROUP_METADATA_KINDS = listOf( GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, SupportedRolesEvent.KIND, + GroupPinnedEvent.KIND, ) /** @@ -47,15 +49,32 @@ private val RELAY_GROUP_METADATA_KINDS = fun filterMetadataToRelayGroup( channel: RelayGroupChannel, since: SincePerRelayMap?, -): List = - channel.relays().toSet().map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = RELAY_GROUP_METADATA_KINDS, - tags = mapOf("d" to listOf(channel.groupId.id)), - since = since?.get(it)?.time, - ), - ) - } +): List { + val relays = channel.relays().toSet() + val directory = + relays.map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = RELAY_GROUP_METADATA_KINDS, + tags = mapOf("d" to listOf(channel.groupId.id)), + since = since?.get(it)?.time, + ), + ) + } + + // Back-fill the bodies of pinned messages by id from the host relay. A pin can point at a + // message older than the 200-item timeline window, which the `h`-scoped timeline filter would + // never return — without this the pin bar shows a blank preview and can't jump to it. No `since`: + // pinned events are immutable, so we want them regardless of age. + val pinnedIds = channel.pinnedEventIds + val pins = + if (pinnedIds.isEmpty()) { + emptyList() + } else { + relays.map { RelayBasedFilter(relay = it, filter = Filter(ids = pinnedIds)) } + } + + return directory + pins +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt index e89cd689e1..16c210e033 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt @@ -67,11 +67,18 @@ fun InviteRelayGroupDialog( .collectAsStateWithLifecycle() val liveChannel = channelState.channel as? RelayGroupChannel ?: channel - // A shareable, cross-client coordinate for the group (opens the chat in any - // NIP-29 client). Null until the relay-signed metadata has loaded. - val nAddr = liveChannel.toNAddr()?.let { "nostr:$it" } val isClosed = liveChannel.isClosed() + // A shareable, cross-client coordinate for the group (opens the chat in any NIP-29 + // client). Null until the relay-signed metadata has loaded. For a closed (invite-only) + // group we append the one-time code as the spec's `?invite=` suffix so the whole + // link is a single tap that auto-joins — no separate code to paste. + val nAddr = + liveChannel.toNAddr()?.let { base -> + val uri = "nostr:$base" + if (isClosed) "$uri?invite=$code" else uri + } + // A join code is only meaningful for closed (invite-only) groups; open groups // join directly from the shared naddr. So mint the kind-9009 invite only when // the group is actually closed, rather than on every dialog open. @@ -116,15 +123,13 @@ fun InviteRelayGroupDialog( } }, confirmButton = { - // Copy the group link, plus the code when the group is closed (so a - // recipient has both to join). Never fall back to copying a code the - // dialog didn't show — for an open group with metadata not yet loaded - // there is simply nothing to copy, so disable the button. - val toCopy = listOfNotNull(nAddr, if (isClosed) code else null).joinToString("\n") + // Copy the single shareable link. For a closed group the code is already + // embedded as `?invite=…`, so one tap joins. Nothing to copy until the + // group's metadata (and thus the naddr) has loaded, so disable until then. TextButton( - enabled = toCopy.isNotBlank(), + enabled = nAddr != null, onClick = { - scope.launch { clipboard.setText(toCopy) } + nAddr?.let { link -> scope.launch { clipboard.setText(link) } } onDismiss() }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt index b84c48923e..fb93f065a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -32,6 +32,7 @@ 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.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -128,7 +129,32 @@ private fun ChannelView( WatchLifecycleAndUpdateModel(feedViewModel) ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel) + // Collect the metadata flow once for the whole screen: it drives both the pinned-message + // bar (kind-39005 pins) and the composer gating (roster membership), and updates the moment + // a pin lands or my join is accepted. + val channelState by channel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val liveChannel = channelState.channel as? RelayGroupChannel ?: channel + + // A pinned-bar tap requests an in-feed jump; the feed consumes it, scrolls + highlights, + // then clears it back to null so the same pin can be tapped again later. + val jumpToNoteId = remember { mutableStateOf(null) } + Column(Modifier.fillMaxHeight()) { + RelayGroupPinnedBar( + channel = liveChannel, + accountViewModel = accountViewModel, + onJumpToNote = { jumpToNoteId.value = it.idHex }, + ) + + RelayGroupSubgroupsBar( + channel = liveChannel, + accountViewModel = accountViewModel, + nav = nav, + ) + Column( modifier = remember { @@ -146,19 +172,15 @@ private fun ChannelView( avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, + jumpToNoteId = jumpToNoteId, + onJumpHandled = { jumpToNoteId.value = null }, ) } Spacer(modifier = DoubleVertSpacer) // NIP-29 relays reject writes from non-members, so only show the composer when the - // relay-signed roster (39001/39002) lists me as a member/mod/admin. Collect the metadata - // flow so the composer appears the moment my join is accepted. Otherwise, explain why. - val channelState by channel - .flow() - .metadata.stateFlow - .collectAsStateWithLifecycle() - val liveChannel = channelState.channel as? RelayGroupChannel ?: channel + // relay-signed roster (39001/39002) lists me as a member/mod/admin. Otherwise, explain why. val canPost = liveChannel.membershipOf(accountViewModel.userProfile().pubkeyHex).isMember() if (canPost) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt index 70b810d053..37a24dea10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -95,6 +95,10 @@ fun RelayGroupMembersScreen( private class RosterEntry( val pubkey: HexKey, val membership: RelayGroupMembership, + // The relay-assigned role names for this member (from the kind-39001 admin list), + // e.g. ["admin"], ["ceo", "gardener"]. Empty for plain members. Used to show the + // real relay-defined role label instead of only the coarse admin/moderator bucket. + val roles: List, ) @Composable @@ -121,9 +125,10 @@ private fun RelayGroupMembers( // is overkill here — rely on relay order but push elevated roles to the top. val roster = remember(channel.admins, channel.members) { + val rolesByPubkey = channel.admins.associate { it.pubKey to it.roles } val everyone = (channel.admins.map { it.pubKey } + channel.members).distinct() everyone - .map { RosterEntry(it, channel.membershipOf(it)) } + .map { RosterEntry(it, channel.membershipOf(it), rolesByPubkey[it] ?: emptyList()) } .sortedBy { it.membership.rank() } } @@ -226,7 +231,7 @@ private fun RelayGroupMemberRow( } } - MemberRoleBadge(entry.membership) + MemberRoleBadge(entry) // Moderators can act on others (not themselves); the relay is the final // authority, but hide obviously-useless menus (a moderator can't touch an admin). @@ -244,23 +249,58 @@ private fun RelayGroupMemberRow( ) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) { - DropdownMenuItem( - text = { Text(stringRes(R.string.relay_group_make_admin)) }, - onClick = { - menuOpen = false - accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN)) - }, - ) - } - if (entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) { - DropdownMenuItem( - text = { Text(stringRes(R.string.relay_group_make_moderator)) }, - onClick = { - menuOpen = false - accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR)) - }, - ) + val declaredRoles = channel.supportedRoles + if (declaredRoles.isNotEmpty()) { + // The relay declares its own role set (kind 39003) — offer exactly those + // instead of the built-in admin/moderator pair. Roles are privilege grants, + // so only admins assign them; the relay is the final authority. + if (viewerIsAdmin) { + declaredRoles.forEach { role -> + val alreadyHasRole = entry.roles.any { it.equals(role.name, true) } + if (!alreadyHasRole) { + DropdownMenuItem( + text = { + Column { + Text(stringRes(R.string.relay_group_assign_role, role.name)) + role.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + onClick = { + menuOpen = false + // Additive: NIP-29 allows multiple roles per member and the menu only + // offers roles they lack, so keep the ones they already hold. + accountViewModel.putRelayGroupUser(channel, entry.pubkey, entry.roles + role.name) + }, + ) + } + } + } + } else { + // No 39003 role set advertised: fall back to the built-in admin/moderator shortcuts. + if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_make_admin)) }, + onClick = { + menuOpen = false + accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN)) + }, + ) + } + if (entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_make_moderator)) }, + onClick = { + menuOpen = false + accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR)) + }, + ) + } } if (entry.membership == RelayGroupMembership.MODERATOR || entry.membership == RelayGroupMembership.ADMIN) { DropdownMenuItem( @@ -310,13 +350,21 @@ private fun RelayGroupMemberRow( } } -/** A small colored pill for an elevated role; plain members get nothing. */ +/** + * A small colored pill for an elevated role; plain members get nothing. + * + * When the relay assigns explicit role labels (kind 39001), those are shown verbatim + * (e.g. `ceo`, `moderator`) so relay-defined roles are visible rather than collapsed + * into the coarse admin/moderator bucket. Falls back to the generic admin/moderator + * label when the member is in the admin list without any named role. + */ @Composable -private fun MemberRoleBadge(membership: RelayGroupMembership) { +private fun MemberRoleBadge(entry: RosterEntry) { val label = - when (membership) { - RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin) - RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator) + when { + entry.roles.isNotEmpty() -> entry.roles.joinToString(", ") + entry.membership == RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin) + entry.membership == RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator) else -> return } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index 66dc72dd20..5f2bbe0c45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -226,6 +226,10 @@ private fun RelayGroupMetadataScaffold( Spacer(Modifier.height(16.dp)) GroupMetadataFields(viewModel) + + Spacer(Modifier.height(16.dp)) + + ParentGroupSection(viewModel, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index 5dc7735f39..db8e535d49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -86,6 +86,20 @@ class RelayGroupMetadataViewModel : ViewModel() { var isHidden by mutableStateOf(false) var isRestricted by mutableStateOf(false) + /** + * Subgroups: the parent group this group nests under, or null for a top-level group. + * Editable via the parent picker. Seeded from the current metadata in [prefillFrom]. + */ + var parentGroupId by mutableStateOf(null) + private set + + /** + * True once the user actually picks a parent in this session. Until then we let the save + * read the group's live parent rather than the (possibly not-yet-loaded) prefilled value, so + * a plain rename can never re-root a subgroup just because its metadata hadn't arrived yet. + */ + private var parentTouched by mutableStateOf(false) + var pickedMedia by mutableStateOf(null) private set @@ -135,6 +149,14 @@ class RelayGroupMetadataViewModel : ViewModel() { topics.value = TextFieldValue(event?.hashtags()?.distinct()?.joinToString(" ") ?: "") // Stored geohashes are mip-mapped into every prefix; the last (longest) is the real one. geohash.value = TextFieldValue(event?.geohashes()?.maxByOrNull { it.length } ?: "") + parentGroupId = channel.parentGroupId() + } + + /** Set (or clear, with null) the group's parent from the picker; marks the form touched. */ + fun setParent(groupId: String?) { + parentGroupId = groupId + parentTouched = true + markTouched() } /** Split the topics field into distinct, non-blank, lowercased hashtags (leading `#` dropped). */ @@ -222,6 +244,7 @@ class RelayGroupMetadataViewModel : ViewModel() { isRestricted = isRestricted, hashtags = hashtags, geohashes = geohashes, + parent = parentGroupId, ) } else { account.editRelayGroupMetadata( @@ -235,6 +258,11 @@ class RelayGroupMetadataViewModel : ViewModel() { isRestricted = isRestricted, hashtags = hashtags, geohashes = geohashes, + // Only override the parent when the user actually re-parented; otherwise let + // Account read the group's live parent so a rename can't accidentally re-root it. + // children likewise defaults to the live child list, so a concurrently-added + // subgroup isn't dropped by this metadata edit. + parent = if (parentTouched) parentGroupId else existing.parentGroupId(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt new file mode 100644 index 0000000000..f3760e4216 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt @@ -0,0 +1,491 @@ +/* + * 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.chats.publicChannels.relayGroup + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +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.Brush +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup +import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent + +/** + * Subgroup authoring: the "Parent group" section of the create/edit form. Shows the + * currently-chosen parent (or top-level) as a tappable hero card and, on tap, opens a + * bottom-sheet picker of the other groups on the same host relay. Selecting one nests + * this group under it via the kind-9002 `parent` tag when the form is saved. + */ +@Composable +fun ParentGroupSection( + viewModel: RelayGroupMetadataViewModel, + accountViewModel: AccountViewModel, +) { + val relay = viewModel.relay ?: return + var pickerOpen by remember { mutableStateOf(false) } + + Text( + text = stringRes(R.string.relay_group_section_structure), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.relay_group_parent_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + + Spacer(Modifier.height(10.dp)) + + ParentSelectorCard( + parentId = viewModel.parentGroupId, + relay = relay, + accountViewModel = accountViewModel, + onClick = { pickerOpen = true }, + ) + + if (pickerOpen) { + ParentGroupPickerSheet( + selfGroupId = viewModel.groupId, + selectedParentId = viewModel.parentGroupId, + relay = relay, + accountViewModel = accountViewModel, + onSelect = { + viewModel.setParent(it) + pickerOpen = false + }, + onDismiss = { pickerOpen = false }, + ) + } +} + +/** The hero card in the form showing the current parent (or top-level) with a gradient badge. */ +@Composable +private fun ParentSelectorCard( + parentId: String?, + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + // Resolve the parent (get-or-create so it's never stuck null when the metadata isn't cached + // yet), warm its single 39000, and observe it so the name/picture fill in as they arrive. + val liveParent: RelayGroupChannel? = + parentId?.let { id -> + val channel = remember(id, relay) { accountViewModel.checkGetOrCreateRelayGroupChannel(GroupId(id, relay)) } + RelayGroupWarmupSubscription(channel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel) + val state by channel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + state.channel as? RelayGroupChannel ?: channel + } + + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + GradientBadge { + if (liveParent != null) { + RobohashFallbackAsyncImage( + robot = liveParent.groupId.id, + model = liveParent.profilePicture(), + contentDescription = liveParent.toBestDisplayName(), + modifier = Modifier.size(46.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = false, + ) + } else { + Icon( + symbol = MaterialSymbols.Home, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(24.dp), + ) + } + } + + Column(Modifier.weight(1f)) { + Text( + text = stringRes(R.string.relay_group_parent_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = liveParent?.toBestDisplayName() ?: stringRes(R.string.relay_group_parent_none), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } +} + +/** A circular badge with a primary→tertiary gradient fill, hosting an icon or avatar. */ +@Composable +private fun GradientBadge(content: @Composable () -> Unit) { + Box( + modifier = + Modifier + .size(46.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary, + ), + ), + ), + contentAlignment = Alignment.Center, + content = { content() }, + ) +} + +/** Bottom-sheet picker of the other groups on this relay, plus a "top-level" choice. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ParentGroupPickerSheet( + selfGroupId: String, + selectedParentId: String?, + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + onSelect: (String?) -> Unit, + onDismiss: () -> Unit, +) { + // Stream the relay's directory while the picker is open so candidates fill in live. + RelayGroupsOnRelaySubscription(relay, accountViewModel.dataSources().relayGroupsOnRelay, accountViewModel) + val relayInfo by loadRelayInfo(relay) + + var query by remember { mutableStateOf("") } + + // A group can't parent itself or any of its own descendants (would make a cycle the relay + // rejects), so exclude them from the candidate set. + val forbidden = + remember(selfGroupId, relay) { + descendantIdsOf(accountViewModel, selfGroupId, relay) + selfGroupId + } + + // Re-read the relay's genuine, relay-signed groups whenever a kind-39000 lands. The initial + // value is empty (cheap) rather than an eager scan — a produceState initial arg is evaluated + // on every recomposition (e.g. each search keystroke), so the scan lives only in the producer. + val candidates by produceState( + initialValue = emptyList(), + relay, + relayInfo, + forbidden, + ) { + value = pickCandidates(accountViewModel, relay, relayInfo, forbidden) + LocalCache + .observeEvents(Filter(kinds = listOf(GroupMetadataEvent.KIND))) + .collect { value = pickCandidates(accountViewModel, relay, relayInfo, forbidden) } + } + + val filtered = + remember(candidates, query) { + if (query.isBlank()) candidates else candidates.filter { it.anyNameStartsWith(query.trim()) } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 24.dp)) { + Text( + text = stringRes(R.string.relay_group_parent_pick_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + leadingIcon = { + Icon( + symbol = MaterialSymbols.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + placeholder = { Text(stringRes(R.string.relay_group_parent_search)) }, + shape = RoundedCornerShape(14.dp), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(12.dp)) + + LazyColumn( + modifier = Modifier.heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + item { + TopLevelRow(selected = selectedParentId == null) { onSelect(null) } + } + items(filtered, key = { it.groupId.id }) { channel -> + GroupPickRow( + channel = channel, + selected = channel.groupId.id == selectedParentId, + accountViewModel = accountViewModel, + onClick = { onSelect(channel.groupId.id) }, + ) + } + if (filtered.isEmpty()) { + item { + Text( + text = stringRes(R.string.relay_group_parent_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + ) + } + } + } + } + } +} + +/** The "make this a top-level group" choice at the head of the picker. */ +@Composable +private fun TopLevelRow( + selected: Boolean, + onClick: () -> Unit, +) { + PickRowScaffold(selected = selected, onClick = onClick) { + GradientBadge { + Icon( + symbol = MaterialSymbols.Home, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(22.dp), + ) + } + Column(Modifier.weight(1f)) { + Text( + text = stringRes(R.string.relay_group_parent_top_level_option), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringRes(R.string.relay_group_parent_none_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + SelectionMark(selected) + } +} + +/** One candidate parent group row: avatar, name, member count and a selection mark. */ +@Composable +private fun GroupPickRow( + channel: RelayGroupChannel, + selected: Boolean, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + val memberCount = channel.memberCount() + PickRowScaffold(selected = selected, onClick = onClick) { + RobohashFallbackAsyncImage( + robot = channel.groupId.id, + model = channel.profilePicture(), + contentDescription = channel.toBestDisplayName(), + modifier = Modifier.size(46.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = false, + ) + Column(Modifier.weight(1f)) { + Text( + text = channel.toBestDisplayName(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = + channel.summary()?.takeIf { it.isNotBlank() } + ?: if (memberCount > 0) { + pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount) + } else { + null + } + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + SelectionMark(selected) + } +} + +/** Shared row chrome: a tonal, rounded, clickable surface that highlights when selected. */ +@Composable +private fun PickRowScaffold( + selected: Boolean, + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit, +) { + val bg by animateColorAsState( + if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + label = "parentRowBg", + ) + Surface( + shape = RoundedCornerShape(16.dp), + color = bg, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + content = content, + ) + } +} + +/** A filled check when selected, an empty ring otherwise. */ +@Composable +private fun SelectionMark(selected: Boolean) { + Icon( + symbol = if (selected) MaterialSymbols.CheckCircle else MaterialSymbols.RadioButtonUnchecked, + contentDescription = null, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.size(24.dp), + ) +} + +/** Genuine, relay-signed groups on [relay], minus the forbidden (self + descendant) ids, name-sorted. */ +private fun pickCandidates( + accountViewModel: AccountViewModel, + relay: NormalizedRelayUrl, + relayInfo: Nip11RelayInformation, + forbidden: Set, +): List = + accountViewModel + .getRelayGroupChannelsOnRelay(relay) + .asSequence() + .filter { it.groupId.id !in forbidden } + .filter { it.event != null && isRelaySignedRelayGroup(it, relayInfo) } + .sortedBy { it.toBestDisplayName().lowercase() } + .toList() + +/** + * The set of group ids reachable as descendants of [rootId] on [relay], following each group's + * advertised `child` links. Visited-guarded so a malformed cycle can't loop forever. + */ +private fun descendantIdsOf( + accountViewModel: AccountViewModel, + rootId: String, + relay: NormalizedRelayUrl, +): Set { + val result = mutableSetOf() + val queue = ArrayDeque() + accountViewModel.getRelayGroupChannelIfExists(GroupId(rootId, relay))?.childGroupIds()?.let { queue.addAll(it) } + while (queue.isNotEmpty()) { + val id = queue.removeFirst() + if (!result.add(id)) continue + accountViewModel.getRelayGroupChannelIfExists(GroupId(id, relay))?.childGroupIds()?.let { queue.addAll(it) } + } + return result +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt new file mode 100644 index 0000000000..1649276dac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt @@ -0,0 +1,158 @@ +/* + * 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.chats.publicChannels.relayGroup + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness + +/** + * Self-hiding NIP-29 pinned-message bar shown under the group's top bar. Renders + * nothing when the group has no pins (kind-39005 empty), so it never obstructs a + * normal chat. When there are pins it shows a single-line preview of the current + * one; tapping [onJumpToNote] scrolls the feed to that message and, if the group + * has more than one pin, advances to the next for the following tap (Telegram-style + * cycling) rather than piling every pin on screen at once. + */ +@Composable +fun RelayGroupPinnedBar( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + onJumpToNote: (Note) -> Unit, +) { + val pinnedIds = channel.pinnedEventIds + if (pinnedIds.isEmpty()) return + + // Newest pin (last in the relay's display order) surfaced first; reset when the list changes. + var index by remember(pinnedIds) { mutableIntStateOf(pinnedIds.lastIndex) } + val safeIndex = index.coerceIn(0, pinnedIds.lastIndex) + val currentId = pinnedIds[safeIndex] + + val note = remember(currentId) { accountViewModel.checkGetOrCreateNote(currentId) } ?: return + + // Fetch + observe the pinned message so its author and content fill in once it loads. + val noteState by observeNote(note, accountViewModel) + val liveNote = noteState.note + + Surface( + color = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + onJumpToNote(liveNote) + // Cycle to the previous (older) pin so a repeat tap walks the history. + if (pinnedIds.size > 1) { + index = if (safeIndex == 0) pinnedIds.lastIndex else safeIndex - 1 + } + }.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = stringRes(R.string.relay_group_pinned_content_description), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringRes(R.string.relay_group_pinned_label), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + if (pinnedIds.size > 1) { + Text( + text = "${safeIndex + 1}/${pinnedIds.size}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Text( + text = pinnedPreview(liveNote) ?: stringRes(R.string.relay_group_pinned_content_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + HorizontalDivider(thickness = DividerThickness, color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + } + } +} + +/** + * A one-line "Author: message" preview for the bar, or just the message when the + * author's name hasn't loaded. Null when the pinned event body isn't loaded yet, so + * the caller can fall back to a generic label. + */ +private fun pinnedPreview(note: Note): String? { + val content = + note.event + ?.content + ?.replace('\n', ' ') + ?.trim() + val author = note.author?.toBestDisplayName() + return when { + content.isNullOrBlank() -> author + author != null -> "$author: $content" + else -> content + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt new file mode 100644 index 0000000000..00b32fda10 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt @@ -0,0 +1,171 @@ +/* + * 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.chats.publicChannels.relayGroup + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** Cap on how many child chips get their own warm-up subscription, to bound relay load. */ +private const val MAX_SUBGROUP_CHIPS = 20 + +/** + * Self-hiding NIP-29 subgroups bar shown under the group's top bar (below the pinned + * bar). Renders nothing for a flat group; when the group is part of a hierarchy it shows + * a breadcrumb chip up to the parent group and a horizontally-scrollable row of chips for + * the direct child subgroups, in the relay's advertised `child` order. Tapping a chip + * opens that group. Parent and children always live on the same host relay. + */ +@Composable +fun RelayGroupSubgroupsBar( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val parentId = channel.parentGroupId() + val childIds = channel.childGroupIds() + if (parentId == null && childIds.isEmpty()) return + + val relay = channel.groupId.relayUrl + + // Load the parent's + every child's kind-39000 (for their names) with ONE relay-directory + // subscription rather than a warm-up per chip — a single REQ that streams metadata for all + // of the relay's groups, versus up to N subscriptions that would each also pull content. + RelayGroupsOnRelaySubscription(relay, accountViewModel.dataSources().relayGroupsOnRelay, accountViewModel) + + Surface( + color = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (parentId != null) { + SubgroupChip( + groupId = GroupId(parentId, relay), + leadingSymbol = MaterialSymbols.ArrowUpward, + accountViewModel = accountViewModel, + nav = nav, + ) + } + childIds.take(MAX_SUBGROUP_CHIPS).forEach { childId -> + SubgroupChip( + groupId = GroupId(childId, relay), + leadingSymbol = MaterialSymbols.Group, + accountViewModel = accountViewModel, + nav = nav, + ) + } + if (childIds.size > MAX_SUBGROUP_CHIPS) { + // Never claim to show all children when we don't: surface the remainder count. + Text( + text = "+${childIds.size - MAX_SUBGROUP_CHIPS}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider(thickness = DividerThickness, color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + } + } +} + +/** + * A single tappable group chip. Resolves the group by id on the shared host relay (its metadata + * is streamed by the bar's one directory subscription) and navigates to that group when tapped. + */ +@Composable +private fun SubgroupChip( + groupId: GroupId, + leadingSymbol: MaterialSymbol, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadRelayGroupChannel(groupId, accountViewModel) { child -> + val childState by child + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val liveChild = childState.channel as? RelayGroupChannel ?: child + + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.clickable { nav.nav(Route.RelayGroup(groupId.id, groupId.relayUrl.url)) }, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + symbol = leadingSymbol, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(16.dp), + ) + Text( + text = liveChild.toBestDisplayName(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} 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 e7393f26e7..2c79c5cb68 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 @@ -97,6 +97,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.hTag +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -514,7 +515,10 @@ open class ChannelNewMessageViewModel : 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) + if (channel is RelayGroupChannel) { + hTag(channel.groupId.id) + previous(channel.previousEventRefs(account.userProfile().pubkeyHex)) + } hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) @@ -671,6 +675,7 @@ open class ChannelNewMessageViewModel : // composer but is missing from the signed event. ChatEvent.reply(tagger.message, replyingToEvent) { hTag(channel.groupId.id) + previous(channel.previousEventRefs(account.userProfile().pubkeyHex)) pTag(replyingToEvent.toPTag()) hashtags(findHashtags(tagger.message)) @@ -685,6 +690,7 @@ open class ChannelNewMessageViewModel : } else { ChatEvent.build(tagger.message) { hTag(channel.groupId.id) + previous(channel.previousEventRefs(account.userProfile().pubkeyHex)) hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 6f8c5dc2fb..135b0a304c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.isConcordTimelineMessage import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -457,10 +458,15 @@ class ChatroomListKnownFeedFilter( .sortedByDefaultFeedOrder() .firstOrNull() - /** The newest decrypted message loaded in this Concord channel, or null if none yet. */ + /** + * The newest decrypted *timeline* message loaded in this Concord channel, or null if none yet. + * Uses [isConcordTimelineMessage] so a trailing kind-1111 thread reply (or a hidden author) + * isn't shown as the Messages-row "last message" — the same predicate the channel feed and the + * unread badge use, so the row summary can't disagree with what opening the channel renders. + */ private fun ConcordChannel.newestConcordNote(account: Account): Note? = notes - .filter { _, it -> account.isAcceptable(it) && it.event != null } + .filter { _, it -> isConcordTimelineMessage(it, account) } .sortedByDefaultFeedOrder() .firstOrNull() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt index 10900d7bc6..4c66c153a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt @@ -25,15 +25,26 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton 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.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingHashtag +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsMutingHashtag import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded @@ -46,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.dal.HashtagFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable @@ -172,4 +184,57 @@ fun HashtagActionOptions( } } } + + HashtagMuteMenu(tag, accountViewModel) +} + +@Composable +fun HashtagMuteMenu( + tag: String, + accountViewModel: AccountViewModel, +) { + var menuOpen by remember { mutableStateOf(false) } + val isMuted by observeUserIsMutingHashtag(tag, accountViewModel) + + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + modifier = Modifier.size(22.dp), + ) + } + + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (isMuted) { + DropdownMenuItem( + text = { Text(stringRes(R.string.unmute_hashtag)) }, + onClick = { + menuOpen = false + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_show_word, + ) + } else { + accountViewModel.showHashtag(tag) + } + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringRes(R.string.mute_hashtag)) }, + onClick = { + menuOpen = false + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_hide_word, + ) + } else { + accountViewModel.hideHashtag(tag) + } + }, + ) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt index d0b82a09e6..7f10209e3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt @@ -34,10 +34,12 @@ 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.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -50,8 +52,11 @@ 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.AccountInfo +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper import com.vitorpamplona.amethyst.service.notifications.NotificationChannels import com.vitorpamplona.amethyst.ui.components.PushNotificationProviderTile @@ -59,6 +64,7 @@ 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.note.toShortDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -89,8 +95,13 @@ fun NotificationSettingsScreen( @Composable private fun DeliverySection(accountViewModel: AccountViewModel) { - val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService - .collectAsStateWithLifecycle() + // Global master switch (persisted, all accounts). produceState + runCatching keeps + // the @Preview safe when Amethyst.instance / LocalPreferences aren't available. + val master by produceState(initialValue = true) { + runCatching { + LocalPreferences.notificationServiceEnabledFlow().collect { value = it } + } + } SettingsSection(R.string.notification_settings_section_delivery) { if (hasPushNotificationProvider()) { @@ -99,18 +110,69 @@ private fun DeliverySection(accountViewModel: AccountViewModel) { } 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() }, + title = R.string.notification_service_master_title, + description = R.string.notification_service_master_description, + checked = master, + onCheckedChange = { LocalPreferences.setNotificationServiceEnabled(it) }, ) } - if (alwaysOn) { + if (master) { + BackgroundAccountsSection() BatteryOptimizationBanner() } } +/** + * Per-account participation list, shown under the master switch: one "keep active in the + * background" toggle per write-enabled account. Each row toggles that account's own + * [AccountSettings.alwaysOnNotificationService]; because LocalPreferences caches one + * AccountSettings per npub, the toggle reaches the same instance the always-on manager + * observes, so participation changes take effect live. + */ +@Composable +private fun BackgroundAccountsSection() { + val accounts by produceState>>(emptyList()) { + value = + runCatching { + LocalPreferences + .allSavedAccounts() + .filter { it.hasPrivKey || it.loggedInWithExternalSigner } + .mapNotNull { info -> + LocalPreferences.loadAccountConfigFromEncryptedStorage(info.npub)?.let { info to it } + } + }.getOrDefault(emptyList()) + } + + if (accounts.isEmpty()) return + + SettingsSection(R.string.notification_service_accounts_title) { + accounts.forEachIndexed { index, (info, settings) -> + if (index > 0) SettingsDivider() + AccountParticipationRow(info, settings) + } + } +} + +@Composable +private fun AccountParticipationRow( + info: AccountInfo, + settings: AccountSettings, +) { + val participates by settings.alwaysOnNotificationService.collectAsStateWithLifecycle() + SettingsControlRow( + icon = MaterialSymbols.AccountCircle, + title = info.npub.toShortDisplay(), + description = stringRes(R.string.notification_service_participation_title), + onClick = { settings.toggleAlwaysOnNotificationService() }, + ) { + Switch( + checked = participates, + onCheckedChange = { settings.toggleAlwaysOnNotificationService() }, + ) + } +} + @Composable private fun DisplaySection(accountViewModel: AccountViewModel) { val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 1add460547..d59844c37f 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -97,6 +97,7 @@ सुरक्षा छलनियाँ निर्गमनांकन अधिक दिखाएँ + अल्प दिखाएँ लैटनिंग चालान समयसीमा समाप्त क्लिंक॰ प्रस्ताव @@ -297,6 +298,9 @@ लेखा जानकारी प्राप्त की जा रही है आमन्त्रण चुकाव… आमन्त्रण अप्राप्त। सम्भाव्यतः योजक निष्क्रिय है अथवा इसके पुनःप्रसारक अनभिगम्य। + यह आमन्त्रण योजक अमान्य है अथवा इस लेखा द्वारा खोला नहीं जा सकता। + यह आमन्त्रण बनाया गया एक नवीनतर क्रमक संस्करण से तथा यहाँ नहीं खोला जा सकता अभी। एक अद्यतनकृत योजक के लिए पूछें अथवा अद्यतन पश्चात पुनःप्रयास करें। + यह आमन्त्रण योजक निराकृत है तथा अनुपयुक्तव्य। नए के लिए पूछें। कोंकोर्ड॰ प्रणाली आप किसी भी कोंकोर्ड प्रणाली से जुडे नहीं अब तक। एक बनाएँ अथवा आमन्त्रण योजक खोलें। कोई प्रणाली नहीं अब तक। @@ -329,6 +333,11 @@ %1$d सदस्य %1$d सदस्य + + %1$d नया सन्देश + %1$d नए सन्देश + + कोई सन्देश नहीं अब तक बनाएँ लोगों को आमन्त्रण आमन्त्रण योजक @@ -1861,6 +1870,63 @@ नया निजी अथवा सार्वजनिक झुण्ड + एक वार्तालाप आरम्भ करें + इस के लिए सबसे अच्छा + अच्छा + समझौते + निजी + रहस्यीकृत समूह + पुनःप्रसारक अवगत + निजी सन्देश + सीधे सन्देश आरम्भ से अन्त तक रहस्यीकृत + एक प्रति एक। पाँच शालाएँ + एक प्रति एक तथा लघु समूह चर्चाएँ आपके परिचित लोगों के साथ। + नया सन्देश + एक प्रति एक तथा लघु समूह + आरम्भ से अन्त तक रहस्यीकृत + तत्पश्चात प्रयोक्ताओं को नहीं जोड सकते + मार्मोट॰ समूह + बृहत्तर निजी समूह + यन्त्रबद्ध + बृहत्तर निजी समूह जो लघुसमूह सीधेसन्देश से उद्भूत हैं। + समूह बनाएँ + लघु सीधेसन्देश मापदण्डस्तर पार करता है + रहस्यीकृत समूह चर्चा + यन्त्रबद्ध। अदृश्य यदि आप अन्यत्र प्रवेशांकन करें + कोंकोर्ड समुदाय + समुदाय प्रणाली अनुसार विभाजित + कार्यशालाएँ + विशाल समुदाय। प्रणालियों अथवा कार्यप्रवाहों अनुसार व्यवस्थित। + समुदाय बनाएँ + विशाल समुदायों तक का मापदण्डस्तर + प्रणाली अथवा कार्यप्रवाह अनुसार विभाजित + स्थापनार्थ और अधिक + सार्वजनिक चर्चा + सार्वजनिक सन्देश एक पुनःप्रसारक पर + नियमनरहित + खुले सार्वजनिक शालाएँ जहाँ कोई भी पत्र प्रकाशित कर सकता है। + नयी सार्वजनिक चर्चा बनाएँ + कोई भी जुड सकता है + सरल तथा आविष्करणशक्य + केवल सार्वजनिक + नियमनरहित + पुनःप्रसारक समूह + पुनःप्रसारक संचालित। सार्वजनिक अथवा निजी + नियमनयुक्त + अपने निवास पुनःप्रसारक द्वारा नियमनकृत समूह। + पुनःप्रसारक समूह वीक्षण + पुनःप्रसारक द्वारा नियमनकृत + सार्वजनिक अथवा निजी + उस एक पुनःप्रसारक से बद्ध + नश्वर चर्चा + जो भी संयोजित हो। अभी इस समय + वर्तमान अभी + सामयिक चर्चा किसी से भी जो अभी संयोजित हो। + चर्चा आरम्भ करें + जो भी संयोजित हो अभी उनसे बात करें + कुछ भी अभिलेखित नहीं + कोई इतिहास नहीं + पुनःप्रसारक सीमित पुनःप्रसारक समूह पंक्ति में पुनःप्रसारक अनुसार @@ -1905,6 +1971,10 @@ सदस्य सूची समूह सम्पादन सूत्र + सन्देश टाँकें + सन्देश से टाँका हटाएँ + टँकित + टँकित सन्देश समूह खोलें इस पुनःप्रसारक पर कोई समूह नहीं अब तक। यह पुनःप्रसारक समूहों का अवलम्बन करने का विज्ञापन नहीं करता (निप॰२९)। इसलिए सम्भावना है कि कोई समूह यहाँ उपलब्ध नहीं। @@ -2039,6 +2109,8 @@ ब्लोस्सम॰ क्रमक प्राप्त नहीं। कृपया एक स्थानीय ब्लोस्सम॰ क्रमक की स्थापना करें इस अभिलेख को देखने के लिए छिपाए गये शब्द नया शब्द अथवा वाक्य छिपाएँ + विषयसूचक मौन करें + विषयसूचक मौन अवस्था हटाएँ मौनकृत सूत्र कोई मौनकृत सूत्र नहीं अज्ञात सूत्र। %1$s @@ -2565,8 +2637,23 @@ निचली मार्गदर्शन पट्टी क्रम पुनःव्यवस्थित करने के लिए खींचें। विकल्पान्तरण करें वस्तु को जोडने अथवा हटाने के लिए निचली पट्टी से। शून्य वस्तु हो तो निचली पट्टी अदृश्य रहेगी। उपलब्ध + आपकी निचली पट्टी + कोई वस्तु टँकित नहीं। निचली पट्टी अदृश्य रहेगा जब तक कि आप न्यूनतम एक को जोडते नहीं। क्रम पुनःव्यवस्था + विकल्प दिखाएँ + कोई प्रियचिह्नित नहीं। एक क्रमक को ताराचिह्नित करें जालसंचारक में यहाँ जोडने के लिए। + कोई समूह नहीं जिससे जुडे हुए हैं अब तक। + खीचें पुनःव्यवस्थित करने के लिए। व्यत्यस्तचिह्न दबाएँ हटाने के लिए + जोडें + जोडा गया + हटाएँ मूलविकल्प पुनःस्थापित करें + मुख्‍य + चर्चाएँ तथा समूह + आप + सूचनावलियाँ + क्रमक तथा जाल + अन्य मुख्यपटल पृष्ठसूचक चयन करें किन पृष्ठसूचक दिखने चाहिए मुख्यपटल पर। जब एक ही सक्रिय है तब पृष्ठसूचक पट्टी छुपाई जाएगी। सभी @@ -2636,6 +2723,15 @@ स्थान हटाएँ विषयवस्तु चेतावनी जोडें विषयवस्तु चेतावनी हटाएँ + %1$s ने %2$s प्रणाली बनाया + %1$s ने प्रणाली बनाया + %1$s ने प्रणाली परिचय का अद्यतन किया + सन्देश वितरण + आवृत + किसी एक पुनःप्रसारक द्वारा इस सन्देश की स्वीकृति की प्रतीक्षा + इस सन्देश के लिए कोई पुनःप्रसारक जानकारी नहीं + न्यूनतम एक पुनःप्रसारक द्वारा स्वीकृत + सभी प्राप्तकर्ताओं के पुनःप्रसारकों को भेजा गया समापन दिनांक जोडें समापन दिनांक हटाएँ समापन दिनांक @@ -2963,7 +3059,7 @@ क्या आप निकटकालिक क्रमदोष सूचनापत्र एक सीधे सन्देश में अमेथिस्ट को भेजना चाहते हैं। कोई व्यक्तिगत जानकारी बाँटी नहीं जाएगी भेजें क्रमक संसाधन उपयोग - विद्युत्कोष तथ्य उपयोग उपभोग ऊर्जा जाल निदान लेखापुस्तक + विद्युत्कोष तथ्य उपयोग उपभोग ऊर्जा जाल निदानतन्त्र लेखापुस्तक आज पिछले सात दिन चलनशील आज @@ -3291,7 +3387,7 @@ सेवासंगणकों का परीक्षण… संयोजित परीक्षा परिणाम - निदानार्थ लक्षण + निदानतन्त्र अन्तिम परीक्षा यन्त्र जानकारी टीएलएस॰ जानकारी @@ -3803,6 +3899,10 @@ समूह नाम प्रविष्ट करें समूह विवरण प्रविष्ट करें (विकल्पात्मक) परिवर्तनों का स्थायी अभिलेखन समूह में किया जाएगा एमएलएस॰ द्वारा तथा सभी सदस्यों तक प्रसारित किया जाएगा। + समूह चिह्न + चित्र जोडें + चित्र परिवर्तन + चित्र हटाएँ समूह विवरण का अद्यतन किया गया अद्यतन असफल : %1$s समूह बनाने में असफल : %1$s diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index ff0ac76ec8..5164ba98d6 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -97,6 +97,7 @@ Biztonsági szűrők Kijelentkezés Továbbiak megjelenítése + Kevesebb megjelenítése Lightning-számla Lejárt CLINK ajánlat @@ -298,6 +299,9 @@ Fiók betöltése… Meghívó beváltása… Nem sikerült lekérni ezt a meghívót. Lehet, hogy a hivatkozás lejárt, vagy az átjátszói nem érhetők el. + Ez a meghívási hivatkozás érvénytelen, vagy nem nyitható meg ezzel a fiókkal. + Ez a meghívó az alkalmazás egy újabb verziójával készült, és itt még nem nyitható meg. Kérjen egy frissített linket, vagy a frissítés után próbálja újra. + Ezt a meghívási hivatkozást visszavonták, és már nem használható. Kérjen egy újat. Concord-csatornák Még nem csatlakozott egyetlen Concord-csatornához sem. Hozzon létre egyet, vagy nyisson meg egy meghívóhivatkozást. Még nincsenek csatornák. @@ -330,6 +334,11 @@ %1$d tag %1$d tag + + %1$d új üzenet + %1$d új üzenet + + Még nincsenek üzenetek Létrehozás Tagok meghívása Meghívóhivatkozás @@ -401,7 +410,7 @@ rövid élettartamú, ezért a csevegési üzenetek idővel eltűnnek Nyilvános csevegés Új nyilvános csevegés létrehozása - MLS-csoport + Marmot-csoport Még nincs üzenet Nyilvános csevegés metaadatai A nyilvános csevegések mindenki számára láthatóak a Nostr-on, és bárki @@ -896,7 +905,7 @@ %1$s és mások Privát üzenet küldése neki: Értesítendő: - Bejegyzések letöltése tőle: + Profiladatok, bejegyzések és interakciók letöltése tőle: Közzététel ebben a szobában Szoba megnyitása Átjátszó használata @@ -908,7 +917,7 @@ Átjátszónkénti felülírások Elfelejtés Utoljára használva: %1$s - Itt még nincs semmi. A globális szabályzat minden átjátszóra vonatkozik. + Itt még nincs semmi. A globális szabály minden átjátszóra vonatkozik. Engedélyezés Megtagadás Forrás: %1$s @@ -1862,6 +1871,63 @@ Új nyilvános vagy privát csoport + Beszélgetés indítása + Legalkalmasabb ehhez: + Előnyök + Kompromisszumok + Privát + Titkosított csoportok + Átjátszó-tudatos + Privát üzenet + Közvetlen üzenetek, végpontok közötti titkosítással + 1:1-5 szoba + Személyes és kiscsoportos beszélgetések olyan emberekkel, akiket ismer. + Új üzenet + 1-1 fős és kiscsoportos + Végpontok közötti titkosítás + Később nem adhatók hozzá felhasználók + Marmot csoport + Nagyobb privát csoportok + Eszközhöz kötött + Nagyobb privát csoportok, amelyek kinőtték a kiscsoportos közvetlen üzeneteket. + Csoport létrehozása + Túlmutat a kis közvetlen üzeneteken + Titkosított csoportos csevegés + Eszközhöz kötött - nem fog megjelenni, ha máshol jelentkezik be + Concord közösség + Csatornákra bontott közösségek + Munkaterületek + Hatalmas közösségek, csatornákba vagy munkafolyamatokba szervezve. + Közösség létrehozása + Hatalmas közösségekre skálázva + Csatornára / munkafolyamatra osztott + Több beállítást igényel + Nyilvános csevegés + Nyilvános üzenetek egy átjátszón + Moderálatlan + Nyitott, nyilvános szobák, ahová bárki küldhet üzenetet. + Nyilvános csevegés létrehozása + Bárki csatlakozhat + Egyszerű és felfedezhető + Csak nyilvános + Nincs moderáció + Átjátszó-csoport + Átjátszó által futtatott, nyilvános vagy privát + Moderált + Az őket kiszolgáló átjátszó által moderált csoportok. + Átjátszó-csoportok böngészése + Átjátszó által moderált + Nyilvános vagy privát + Ahhoz az egy átjátszóhoz kötött + Eltűnő csevegés + Bárki, aki éppen online van + Most élő + Pillanatnyi csevegés bárkivel, aki éppen online van. + Csevegés indítása + Beszélgetés azokkal, akik most online vannak + Semmi sem mentődik el + Nincs előzmény + Átjátszóra korlátozott Átjátszócsoportok Beágyazott Átjátszó szerint @@ -1906,6 +1972,10 @@ Tagok Csoport szerkesztése Szálak + Üzenet kitűzése + Üzenet kitűzésének megszüntetése + Kitűzött + Kitűzött üzenetek Csoport megnyitása Még nincsenek csoportok ezen az átjátszón. Ez az átjátszó nem jelzi a csoportok támogatását (NIP-29), így előfordulhat, hogy nem futtat egyet sem. @@ -2040,6 +2110,8 @@ Nem található Blossom-alkalmazás. Telepítsen egy helyi Blossom-alkalmazást a fájl megtekintéséhez Rejtett szavak Új szó vagy mondat elrejtése + Kulcsszó némítása + Kulcsszó némításának megszüntetése Némított szálak Nincsenek némított szálak Ismeretlen szál · %1$s @@ -2564,10 +2636,25 @@ Függőben lévő megerősítés Gyors reakciók megváltoztatása Alsó navigációs sáv - Húzással rendezheti át az elemek sorrendjét. A kapcsolóval elemeket adhat hozzá az alsó sávhoz, illetve eltávolíthat onnan. Ha nincs elem, az alsó sáv el van rejtve. + A kitűzött elemeket húzással rendezheti át. Nyissa meg az alatta lévő kategóriát, és kapcsolja be vagy ki az elemet annak hozzáadásához vagy eltávolításához. Nyissa meg a „Böngésző” vagy egy csevegéstípust, hogy kedvencet vagy egy adott csoportot kitűzzün. Ha nincs elem, az alsó sáv el van rejtve. Elérhető + Saját alsó sáv + Nincsenek kitűzött elemek. Az alsó sáv rejtve marad, amíg hozzá nem ad legalább egyet. Újrarendezés + Beállítások megjelenítése + Még nincsenek kedvencek. Jelöljön meg csillaggal egy alkalmazást a böngészőben, hogy hozzáadja ide. + Még nincsenek csatlakozott csoportok. + Húzza a rendezéshez, koppintson az ✕ gombra az eltávolításhoz + Hozzáadás + Hozzáadva + Eltávolítás Alapértelmezés visszaállítása + Fő + Csevegések és csoportok + Ön + Hírfolyamok + Alkalmazások és web + Egyéb Kezdőlap lapjai Válassza ki, mely lapok jelenjenek meg a kezdőlapon. Ha csak egy lap aktív, akkor a lapsáv rejtett. Minden @@ -2637,6 +2724,15 @@ Helyszín eltávolítása Tartalmi figyelmeztetés hozzáadása Tartalmi figyelmeztetés eltávolítása + %1$s létrehozta a(z) %2$s nevű csatornát + %1$s létrehozta a csatornát + %1$s frissítette a csatorna profilját + Üzenet kézbesítése + Bezárás + Várakozás egy átjátszóra az üzenet elfogadásához + Nincs átjátszó-információ ehhez az üzenethez + Legalább egy átjátszó elfogadta + Kézbesítve az összes címzett átjátszójára Lejárati dátum hozzáadása Lejárati dátum törlése Lejárati dátum @@ -3804,6 +3900,10 @@ Adja meg a csoport nevét Adja meg a csoport leírását (nem kötelező) A változtatások az MLS-en keresztül kerülnek érvényesítésre a csoportban, és továbbítódnak minden tag felé. + Csoport ikonja + Fotó hozzáadása + Fotó módosítása + Fotó eltávolítása Csoportadatok frissítve Nem sikerült frissíteni: %1$s Nem sikerült létrehozni a csoportot: %1$s diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index ef3f34633a..2e7966e17b 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -103,6 +103,7 @@ Filtry bezpieczeństwa Wyloguj się Pokaż Więcej + Pokaż mniej Faktura LN Wygasła Oferta CLINK @@ -2778,6 +2779,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Usuń lokalizację Dodaj ostrzeżenie o treści Usuń ostrzeżenie o treści + %1$s utworzył kanał %2$s + %1$s utworzył kanał + %1$s zaktualizował profil kanału + Dostarczenie wiadomości + Zamknij + Oczekiwanie na akceptację tej wiadomości przez transmiter + Brak informacji o transmiterze tej wiadomości + Akceptowane przez co najmniej jeden transmiter + Dostarczono do transmiterów wszystkich odbiorców Dodaj datę wygaśnięcia Usuń datę wygaśnięcia Data wygaśnięcia @@ -3114,6 +3124,83 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Ostatnie 7 dni Komórka dzisiaj Wi-Fi dzisiaj + Czas transmitera dzisiaj + W aplikacji dzisiaj + Dane dziennie + Internet mobilny (MB) + Wi-Fi (MB) + Aktywność (7 dni) + Dane mobilny (w tle) + Dane komórkowe (w aplikacji) + Dane Wi-Fi + Czas połączenia z transmiterem + \u2026 w tle na komórce + Przetwarzanie powiadomień (procesor w stanie czuwania) + Uruchomione zadania w tle + Ponowne połączenia transmiterów (nieudane) + Żądania sieci Web + Wybudzanie radiowe podczas pobierania (szacunkowo) + Aktywny czas transferu + Czas odtwarzania multimediów + Podpisy zweryfikowane + Czas wykorzystania procesora + Czas w aplikacji + Rozpoczęcie działania aplikacji + Wiadomości odszyfrowane + Zdalne podpisy + Wydobycie Proof-of work + Czas działania sieci Tor + Czas rozmów + Czas w pokojach audio + Wykrywanie lokalizacji + Zużycie baterii (w aplikacji/tle) + Usługa powiadomień w tle + Utrzymuje otwarte połączenia z transmiterami nawet wtedy, gdy aplikacja jest zamknięta, dzięki czemu powiadomienia docierają bez pośrednictwa serwerów push firmy Google. Gdy ta funkcja jest włączona, zazwyczaj powoduje największe zużycie baterii w tle. Jeśli ważniejsza jest dla Ciebie oszczędność baterii niż natychmiastowe powiadomienia, możesz zmienić sposób dostarczania powiadomień lub wyłączyć tę funkcję. + Czas pracy + Połączenia transmiterów utrzymywane po zamknięciu + W międzyczasie rozładowała się bateria (całe urządzenie) + Zmień ustawienia powiadomień + Ustawienia prywatności + Bateria / godz. w aplikacji + Dane / godz. w aplikacji + Średnia liczba połączeń z transmiterem + Dane komórkowe wg funkcji (7 dni) + %1$s · %2$s łącznie + Czas wg ekranu (7 dni) + Wbudowany Tor + W celu zapewnienia prywatności przekierowuje ruch aplikacji przez sieć Tor. Podczas działania aplikacja zużywa więcej energii baterii na szyfrowanie i utrzymanie połączenia, a przy każdym uruchomieniu następuje bootstrap. Jeśli pozwala na to model ryzyka, korzystanie z sieci Tor można dostosować lub wyłączyć. + Dane wg funkcji (7 dni) + Synchronizacja Transmiterów + Zdjęcia + Audio & wideo + Wgrane + Portfel & zaps + Weryfikacja adresu + Podgląd linku + Rejestracja Push + Inne + Nie odnotowano jeszcze żadnego użycia. + Pamięć teraz + Pamięć aplikacji (heap) + Pamięć rodzima + Pamięć podręczna (RAM) obrazu + Image cache (disk) + Notatki w pamięci + Profile w pamięci + Adresowalne wydarzenia w pamięci + Listy czatów w pamięci + Klasa pamięci urządzenia + Udostępnij deweloperom + Jeśli zauważysz, że aplikacja Amethyst nadmiernie zużywa baterię lub transfer danych, możesz przesłać ten raport do twórców w zaszyfrowanej wiadomości prywatnej. Zawiera on wyłącznie liczby widoczne na tym ekranie oraz związane z nimi liczniki techniczne \u2014 nie zawiera żadnych postów, kontaktów ani szczegółów dotyczących przeglądania stron. Żadne dane nie zostaną wysłane, dopóki nie klikniesz przycisku „Wyślij” na ekranie wiadomości. + Wyślij raport za pośrednictwem DM + Wykryto wysokie użycie zasobów + W ostatnim czasie Amethyst zużył więcej zasobów, niż oczekiwano: %1$s. Czy chcesz wysłać raport dotyczący zużycia do twórców w zaszyfrowanej wiadomości prywatnej? Przed wysłaniem zobaczysz pełną treść raportu. + %1$s danych komórkowych w tle w ciągu jednego dnia + %1$s połączeń z transmiterem w ciągu jednego dnia, gdy aplikacja działała w tle przy połączeniu komórkowym + procesor pozostawał w stanie czuwania przez %1$s w celu przetwarzania powiadomień w ciągu jednego dnia + Przejrzyj & wyślij + Nie teraz + Nie pytaj ponownie Ta wiadomość zniknie za %1$s Wybierz Sygnatariusza Już jest na liście @@ -3478,6 +3565,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Odtwarzanie Automatycznie + Edytuj pseudonim + Widoczne tylko dla Ciebie Zrzuć na urządzenie Przestań zrzucać @@ -3706,6 +3795,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Dodawane na końcu wiadomości podczas tworzenia nowego wpisu, odpowiedzi, cytatu lub artykułu. Aby wyłączyć tę funkcję, pozostaw to pole puste. Twój podpis Dowód wykonania + Autor wykorzystał moc obliczeniową, aby wygenerować tę notatkę przy poziomie trudności %1$s — jest to pieczęć typu „proof-of-work”, która sprawia, że spamowanie staje się kosztowne. Stopień trudności Przed opublikowaniem postów generuje się w nich dowód wykonania pracy zgodny ze standardem NIP-13, dzięki czemu transmitery i czytelnicy mogą je zweryfikować pod kątem spamu. Im wyższa wartość, tym wydobycie trwa wykładniczo dłużej. Po wygenerowaniu posty są publikowane w tle. Wył. @@ -3899,6 +3989,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Wpisz nazwę grupy Dodaj opis grupy (opcjonalnie) Zmiany zostaną zatwierdzone w grupie za pośrednictwem MLS i przekazane wszystkim członkom. + Ikona grupy + Dodaj zdjęcie + Zmień zdjęcie + Usuń zdjęcie Informacje o grupie zaktualizowane Nie udało się zaktualizować: %1$s Nie udało się utworzyć grupy: %1$s diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index aabd2942ac..ec088ef1ea 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -94,6 +94,7 @@ 安全过滤器 退出 显示更多 + 显示较少 闪电发票 已过期 CLINK 报价 @@ -294,6 +295,9 @@ 正在载入帐户 兑换邀请中… 无法获取此邀请。链接可能已过期或其中继不可访问。 + 此邀请链接无效或无法用此账户打开。 + 此邀请是用新版本的应用创建,尚无法在此处打开。 请求已更新的链接,或在更新后重试。 + 此邀请链接已被撤销,不能再使用。请求新链接。 Concord 频道 您尚未加入任何Concord频道。创建一个频道,或者打开一个邀请链接。 尚无频道。 @@ -324,6 +328,10 @@ %1$d 位成员 + + %1$d 条新消息 + + 尚无消息 创建 邀请其他人 邀请链接 @@ -394,7 +402,7 @@ 因此聊天信息会随着时间的推移而消失。 公共聊天 创建新的公开聊天 - Marmot 群 + Marmot 群组 尚无消息 公开聊天元数据 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: @@ -1840,6 +1848,63 @@ 新建公开或私人群组 + 开始对话 + 最适合 + 好 + 折衷办法 + 私密 + 加密群组 + 支持中继 + 私信 + 私信,端到端加密 + 1:1–5 个房间 + 和你认识的人的一对一及小型群聊。 + 新消息 + 1-1 & 小型群组 + 端到端加密 + 稍后无法添加用户 + Marmot 群 + 较大的私密群 + 绑定设备 + 规模较大的私密群组,不再适合小型群组私信。 + 创建群组 + 发送大量私信 + 加密的群组聊天 + 绑定设备-如果您在其他地方登录则不会显示 + Concord 社区 + 按频道拆分社区 + 工作区域 + 根据频道或工作流组织的大规模社区。 + 创建社区 + 扩大到超大社区 + 按照频道/工作流拆分 + 更多待设置 + 公开聊天 + 中继上的公开消息 + 无人管理 + 任何人都可以发帖的开放公共房间。 + 创建公开聊天 + 任何人都可以加入 + 简单 & 可发现 + 仅公开 + 无审核 + 中继群组 + 中继运行,公开或私密 + 有审核 + 群组由托管它们的中继管理。 + 浏览中继群组 + 由中继管理 + 公开或私密 + 绑定单一中继 + 临时聊天 + 不论谁在线,立马聊天 + 当前在线 + 与当前在线的任何人进行即时聊天。 + 开始聊天 + 与当前在线的人交谈 + 不保存任何内容 + 没有历史记录 + 限于中继范围 中继组 行内 根据中继 @@ -1872,6 +1937,7 @@ 成员 设为管理员 设为协管 + 分配角色: %1$s 删除职位 从群组中删除 从此群中移除 %1$s ?在重新添加或重新邀请之前这些人将失去访问权限。 @@ -1884,6 +1950,10 @@ 成员 编辑群 主题帖 + 固定消息 + 取消固定消息 + 已固定 + 固定的消息 打开群组 此中继上尚没有群。 这个中继不广播群组支持 (NIP-29),所以它可能不托管任何群组。 @@ -1903,6 +1973,15 @@ 比特币,nostr,艺术品 位置 (gehash) u0nd + 结构 + 把这个群置于父级下方以构建一个层次结构。 + 父群 + 顶层群 + 选择父群 + 搜索群 + 没有父级(顶层) + 此群位于顶层 + 此中继上尚没有其他群 权限 私密 只有成员可以阅读消息。 @@ -2014,6 +2093,8 @@ 找不到Blossom应用。请安装本地Blossom应用查看此文件 隐藏单词 隐藏新单词或句子 + 静音话题标签 + 取消静音话题标签 静音的主题帖 无静音的主题帖 未知主题帖 · %1$s @@ -2529,8 +2610,23 @@ 底部导航栏 拖动可重新排序。切换可从底部栏中添加或删除一个项目。无项目则底部栏隐藏。 可用 + 您的底部栏 + 没有固定的项目。在您添加至少一个项目前不显示底部栏。 调整顺序 + 显示选项 + 尚无收藏内容。在浏览器中给应用添加星标以在此添加。 + 尚未加入任何群组。 + 拖动以重新排序 · 点击✕删除 + 添加 + 已添加 + 删除 恢复默认 + 主要 + 聊天 & 群组 + 您 + 订阅源 + Apps & Web + 其它 主页标签 选择主屏幕上出现的标签页。当只有一个标签处于活动状态时,标签栏将被隐藏。 全部 @@ -2600,6 +2696,15 @@ 移除地点 添加内容警告 移除内容警告 + %1$s 创建了频道 %2$s + %1$s 创建了频道 + %1$s 更新了频道配置文件 + 消息传递 + 关闭 + 正在等待中继接受此消息 + 没有此消息的中继信息 + 被至少一个中继接受 + 已传送到所有收信人的中继 添加过期日期 删除过期日期 过期日期 @@ -2945,7 +3050,7 @@ 已运行后台任务 中继重连(失败的) Web 请求 - 下载的广播唤醒数(预估) + 为下载进行的广播唤醒数(预估) 有效传输时间 媒体播放时间 签名已验证 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1bf7700459..b2b2532897 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -309,6 +309,9 @@ Loading account Redeeming invite… Could not fetch this invite. The link may be expired or its relays unreachable. + This invite link is invalid or can\'t be opened with this account. + This invite link can\'t be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link. + This invite link has been revoked and can no longer be used. Ask for a new one. Concord Channels You haven\'t joined any Concord Channels yet. Create one, or open an invite link. No channels yet. @@ -341,6 +344,11 @@ %1$d member %1$d members + + %1$d new message + %1$d new messages + + No messages yet Create Invite people Invite link @@ -1695,6 +1703,15 @@ Always-on notification service Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message. + Notification service + On + Off + + Background notification service + Master switch for real-time background notifications. Turn off to save battery — like an airplane mode. When off, no account stays connected in the background, and it stays off (across restarts) until you turn it back on. + Accounts active in the background + Keep this account active in the background + Split notifications by Follows Show two notification tabs — Following (people you follow) and Everyone. The unread indicator glows only for activity from people you follow. Show Messages @@ -2150,6 +2167,7 @@ Members Make admin Make moderator + Assign role: %1$s Remove role Remove from group Remove %1$s from this group? They will lose access until re-added or re-invited. @@ -2162,6 +2180,10 @@ Members Edit group Threads + Pin message + Unpin message + Pinned + Pinned messages Open group No groups on this relay yet. This relay does not advertise support for groups (NIP-29), so it may not host any. @@ -2181,6 +2203,15 @@ bitcoin, nostr, art Location (geohash) u0nd + Structure + Nest this group under a parent to build a hierarchy. + Parent group + Top-level group + Choose a parent group + Search groups + No parent (top-level) + This group sits at the top level + No other groups on this relay yet Permissions Private Only members can read messages. @@ -2320,6 +2351,8 @@ Hidden Words Hide new word or sentence + Mute hashtag + Unmute hashtag Muted threads No muted threads Unknown thread · %1$s diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt index e3e71b44de..70f3a8d30f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runCurrent @@ -70,6 +71,7 @@ class RelayAuthPromptBusTest { assertEquals(UserAuthChoice.DISMISS, bus.requestDecision(relay, emptyList())) } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun retainsPromptForALateSubscriberSoItIsNotLost() = runTest { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index 6be24d2942..ac4c066788 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -142,6 +143,7 @@ class ResourceUsageAccountantTest { assertEquals(12L, store.allDays()[200]?.get("x")) } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun hookAddsAreDrainedInPlaceAndNeverRearmTheFlushLoop() = runTest { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index c975e232e0..8b6bbb6528 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent @@ -314,6 +315,17 @@ object ConcordActions { token: ByteArray, ): CommunityInvite? = ConcordInviteBundle.parse(bundleEvent, token)?.takeIf { ConcordInviteBundle.validate(it) } + /** + * Resolves every event fetched at an invite's addressable coordinate into one + * [InviteBundleStatus] (live / revoked / unreadable / absent) per CORD-05 §2, so a + * redeeming client honours a `vsk=9` revocation tombstone and reports why a link + * can't be opened instead of retrying blindly. + */ + fun classifyInvite( + wraps: List, + token: ByteArray, + ): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token) + /** Derives the control plane described by a redeemed [invite] so the joiner can read it. */ fun controlPlaneFor(invite: CommunityInvite): GroupKey = controlPlane(invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt index 960d123b87..061991deeb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt @@ -116,6 +116,24 @@ abstract class Channel : NotesGatherer { } } + /** + * Refresh derived state after an already-[addNote]'d [note] finishes loading its event. + * + * Most channels attach a note whose event is already set, so [addNote] can pick [lastNote] and + * order the feed right away. Concord is the exception: it attaches a message row to its channel + * *before* the rumor is consumed (so the row already carries its gatherer when it flows through + * the Messages filter), which means [addNote] ran while `createdAt()` was still null and could + * neither set [lastNote] nor sort correctly. Once the event lands, call this so [lastNote] and + * any notes-flow observers (row previews, unread counts, ordering) reflect the real message. + */ + fun refreshAfterEventLoad(note: Note) { + if (!notes.containsKey(note.idHex)) return + if ((note.createdAt() ?: 0L) > (lastNote?.createdAt() ?: 0L)) { + lastNote = note + } + flowSet?.notes?.invalidateData() + } + override fun removeNote(note: Note) { if (notes.containsKey(note.idHex)) { notes.remove(note.idHex) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index e8583e0345..2cb3af0592 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -61,11 +61,15 @@ data class LiveHiddenUsers( val spammers: Set = emptySet(), val hiddenWords: Set = emptySet(), val mutedThreads: Set = emptySet(), + // Lowercased hashtags (without the leading '#') the user has muted via NIP-51 `"t"` entries. + val hiddenHashtags: Set = emptySet(), val maxHashtagLimit: Int = 8, ) { fun isUserHidden(userHex: String) = hiddenUsers.contains(userHex) || spammers.contains(userHex) fun isThreadMuted(rootHex: String) = mutedThreads.contains(rootHex) + + fun isHashtagHidden(hashtag: String) = hiddenHashtags.contains(hashtag.lowercase()) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 61cec122e3..802ba935b7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -1309,6 +1309,12 @@ open class Note( } } + if (accountChoices.hiddenHashtags.isNotEmpty()) { + if (thisEvent.anyHashTag { it.lowercase() in accountChoices.hiddenHashtags }) { + return true + } + } + if (accountChoices.hiddenWordsCase.isNotEmpty()) { if (thisEvent is BaseThreadedEvent && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) { return true diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 4a05bfa61c..2605b0c13c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -31,7 +31,10 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.RoleTag import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -70,6 +73,24 @@ class RelayGroupChannel( private set private var adminsUpdatedAt: Long = 0 + /** + * Relay-signed pinned message ids (kind 39005), in the relay's display order. + * Pinning replaces the whole list, so this is always the full current set. + */ + var pinnedEventIds: List = emptyList() + private set + private var pinnedUpdatedAt: Long = 0 + + /** + * Relay-declared roles this group supports (kind 39003), e.g. `admin`, `moderator`, + * `ceo`. Empty until (or unless) the relay publishes a 39003 for the group. These are + * the role names a moderation UI should offer when assigning a role, since the exact + * set is relay-defined (NIP-29 §Group management). + */ + var supportedRoles: List = emptyList() + private set + private var supportedRolesUpdatedAt: Long = 0 + /** * Members ∪ admins, recomputed only when a roster event lands. [memberCount] and the discovery * feed read this per note / per recomposition, so caching it avoids rebuilding the set each read. @@ -128,6 +149,15 @@ class RelayGroupChannel( fun hasLivekit(): Boolean = event?.hasLivekit() ?: false + /** Subgroups: the id of this group's parent on the same host relay, or null when it's a root. */ + fun parentGroupId(): String? = event?.parent() + + /** Subgroups: the ordered ids of this group's direct children (empty when it has none). */ + fun childGroupIds(): List = event?.children() ?: emptyList() + + /** Whether this group sits under a parent group (i.e. it is a subgroup). */ + fun isSubgroup(): Boolean = event?.isRoot() == false + fun updateGroupInfo( event: GroupMetadataEvent, eventNote: Note? = null, @@ -158,6 +188,51 @@ class RelayGroupChannel( updateChannelInfo() } + fun updatePinned(event: GroupPinnedEvent) { + // Only newer lists supersede; equal-or-older is dropped so a duplicate + // arrival isn't reprocessed (no redundant emit). + if (event.createdAt <= pinnedUpdatedAt) return + pinnedEventIds = event.pinnedEventIds() + pinnedUpdatedAt = event.createdAt + updateChannelInfo() + } + + fun updateSupportedRoles(event: SupportedRolesEvent) { + // Only newer definitions supersede; equal-or-older is dropped (no redundant emit). + if (event.createdAt <= supportedRolesUpdatedAt) return + supportedRoles = event.roles() + supportedRolesUpdatedAt = event.createdAt + updateChannelInfo() + } + + fun isPinned(eventId: HexKey): Boolean = eventId in pinnedEventIds + + /** + * NIP-29 timeline references (`previous` tag) for an event about to be sent to this + * group. Returns the first-8-hex-char id prefixes of the most recent events seen here + * from the host relay, excluding [selfPubkey]'s own posts. + * + * The spec uses these to stop a group's events from being replayed out of context on a + * forked relay: a relay rejects an event whose `previous` refs it doesn't recognise, so + * we only draw from events we actually received in this channel (guaranteeing the host + * relay has them) and cap at the spec's window of the last 50. It recommends including + * at least 3; [max] bounds how many we attach. + */ + fun previousEventRefs( + selfPubkey: HexKey, + max: Int = 8, + ): List = + notes + .mapNotNull { _, note -> + val createdAt = note.createdAt() + val author = note.author + // Require a resolved author so an unlinked note can't slip past the self-exclusion + // and make us reference our own event (the very thing `previous` guards against). + if (createdAt != null && author != null && author.pubkeyHex != selfPubkey) note to createdAt else null + }.sortedByDescending { it.second } + .take(max) + .map { it.first.idHex.take(8) } + /** * The shareable NIP-19 `naddr` coordinate for this group's metadata (kind * 39000, authored by the relay's own key, with the host relay as a hint), or diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt new file mode 100644 index 0000000000..4bec8035ec --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt @@ -0,0 +1,91 @@ +/* + * 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.commons.model.nipB7Blossom + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +/** + * Shared, platform-agnostic state holder for the user's Blossom media server + * list (NIP-B7 / kind 10063 [BlossomServersEvent]). + * + * This is the same event kind the Amethyst mobile app reads through its own + * `BlossomServerListState`: it loads the addressable event from the injected + * [ICacheProvider] and exposes the declared server URLs as a [StateFlow]. Both + * the Android and Desktop front ends can consume this so a server list + * configured on one client shows up on the other. + */ +class BlossomServerListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val blossomListNote = cache.getOrCreateAddressableNote(getBlossomServersAddress()) + + fun getBlossomServersAddress() = BlossomServersEvent.createAddress(signer.pubKey) + + fun getBlossomServersListFlow(): StateFlow = blossomListNote.flow().metadata.stateFlow + + fun getBlossomServersList(): BlossomServersEvent? = blossomListNote.event as? BlossomServersEvent + + fun normalizeServers(note: Note): List = (note.event as? BlossomServersEvent)?.servers() ?: emptyList() + + val flow: StateFlow> = + getBlossomServersListFlow() + .map { normalizeServers(it.note) } + .onStart { emit(normalizeServers(blossomListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun saveBlossomServersList(servers: List): BlossomServersEvent { + val serverList = getBlossomServersList() + + return if (serverList != null && serverList.tags.isNotEmpty()) { + BlossomServersEvent.updateRelayList( + earlierVersion = serverList, + servers = servers, + signer = signer, + ) + } else { + BlossomServersEvent.createFromScratch( + relays = servers, + signer = signer, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequests.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequests.kt new file mode 100644 index 0000000000..b13d10c1d8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequests.kt @@ -0,0 +1,107 @@ +/* + * 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.commons.relayClient.auth + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Holds the set of tier-2 NIP-42 AUTH challenges awaiting a decision and the + * logic that settles them. Platform-agnostic (lives in `commons`) so the state + * and, crucially, the cold-boot race fix are unit-testable without any desktop + * wiring (relay client, LocalCache, Compose). + * + * A [PendingAuthApproval] is produced by [AuthApprovalPolicy.classify] when a + * relay is neither pre-approved nor blocked; the suspended signer awaits its + * [PendingAuthApproval.decision]. This class owns the live set the banner + * renders and the three ways an entry leaves it: + * + * - [resolve] — the user picked `[Once] [Always] [Never]`. + * - [autoApproveNowTrusted] — the relay became tier-1 after the challenge was + * surfaced (the cold-boot race, see that method). + * - [cancelAll] — logout / account switch tears everything down. + */ +class AuthApprovalRequests { + private val _pending = MutableStateFlow>(persistentMapOf()) + + /** The live set of pending tier-2 challenges the AUTH banner renders. */ + val pending: StateFlow> = _pending.asStateFlow() + + /** Track a newly surfaced tier-2 challenge. Last write per relay wins. */ + fun add(approval: PendingAuthApproval) { + _pending.update { it.put(approval.relayUrl, approval) } + } + + /** + * Settle a pending challenge with the user's pick. Removes the entry + * BEFORE completing the deferred so the suspended signer wakes exactly + * once. Returns false if there was nothing pending for [relayUrl]. + */ + fun resolve( + relayUrl: NormalizedRelayUrl, + scope: AuthApprovalScope, + ): Boolean { + val approval = _pending.value[relayUrl] ?: return false + _pending.update { it.remove(relayUrl) } + return approval.decision.complete(scope) + } + + /** + * Auto-approve any pending challenge whose relay is now tier-1 (present in + * [trusted]). + * + * Fixes the cold-boot race: on startup an AUTH-required DM-inbox relay + * (kind:10050) often sends its challenge before the account's own kind:10050 + * list has been fetched, so [AuthApprovalPolicy.classify] — which reads the + * trusted set exactly once — sees an empty set and surfaces a tier-2 prompt + * for a relay that should have auto-signed. Nothing re-evaluates that + * pending decision when the list finally loads, leaving a spurious banner. + * + * When the DM-inbox set updates, call this: every pending relay now in + * [trusted] is settled with [AuthApprovalScope.ONCE] — sign this session but + * do NOT persist, since it is trusted by identity (it is the user's own + * inbox), not by an explicit user grant. + */ + fun autoApproveNowTrusted(trusted: Set) { + // Snapshot the relays to settle so we don't mutate while iterating the + // live map. ONCE = sign this session, never persist (trusted by + // identity, not by an explicit user grant). + _pending.value.keys + .filter { it in trusted } + .forEach { relayUrl -> resolve(relayUrl, AuthApprovalScope.ONCE) } + } + + /** + * Cancel every pending challenge, completing each with + * [AuthApprovalScope.BLOCKED] so suspended signers unblock (and drop the + * AUTH) rather than hang. Used on logout / account switch. + */ + fun cancelAll() { + val snapshot = _pending.value + _pending.value = persistentMapOf() + snapshot.values.forEach { it.decision.complete(AuthApprovalScope.BLOCKED) } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt index 301ebd7e98..6c75108430 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt @@ -30,20 +30,22 @@ import kotlinx.coroutines.sync.withLock /** * Resolves a recipient's NIP-17 inbox relays (kind:10050) for DM delivery. * - * Three-layer lookup, in order: + * Lookup order: * * 1. **LocalCache hit** — the caller has already seen the user's kind:10050 * via the normal feed subscription pipeline. Cheapest; no I/O. * 2. **In-memory LRU cache** — a prior resolve() succeeded for this pubkey - * within the TTL. Avoids re-querying indexers when the user opens a - * conversation list and clicks several recipients in sequence. - * 3. **Indexer fan-out** — query a curated set of indexer relays for the - * user's kind:10050 via [RecipientRelayFetcher]. The client passed in - * here MUST be an **unauthenticated** instance (no [RelayAuthenticator] - * attached) — otherwise an indexer's AUTH challenge would extract an - * identity-key signature from the user, turning the metadata leak - * "indexer learns who we want to DM" into "indexer learns user U wants - * to DM pubkey X". + * within the TTL. Avoids re-querying when the user opens a conversation + * list and clicks several recipients in sequence. + * 3. **Network fan-out** ([resolve] does this in two phases) — query the + * curated indexers **plus** the recipient's known write relays for + * kind:10050/10002, and, if the indexers didn't have the 10050, read it + * directly from the recipient's NIP-65 write (outbox) relays — the + * canonical location per NIP-65. The client passed in here MUST be an + * **unauthenticated** instance (no [RelayAuthenticator] attached) — + * otherwise a relay's AUTH challenge would extract an identity-key + * signature from the user, turning the metadata leak "some relay learns + * who we want to DM" into "that relay learns user U wants to DM pubkey X". * * Filters to **kind:10050 only**. Per NIP-17 §Publishing, gift wraps MUST * land on relays in the recipient's kind:10050; this resolver never @@ -62,6 +64,12 @@ import kotlinx.coroutines.sync.withLock * @property localLookup Callback the resolver invokes first to check the * LocalCache — returns the user's current kind:10050 list or null if * unknown. Allows commons/headless callers to plug in a CLI-safe lookup. + * @property outboxLookup Callback returning the recipient's NIP-65 write + * (outbox) relays from the recipient's cached kind:10002, or null/empty when + * unknown. Per the NIP-65 outbox model a user's replaceable events — kind:10050 + * included — live on their own write relays, so those are queried directly (in + * addition to the curated indexers) rather than trusting the indexers to have + * mirrored the 10050. Returns null by default (indexer-only, e.g. the CLI). * @property cacheTtlMs LRU cache TTL. 1h matches the brainstorm's open * question; configurable here for tests. * @property cacheSize LRU bound. 100 entries × ~200 bytes each is trivial @@ -71,6 +79,7 @@ class DmInboxRelayResolver( private val unauthenticatedClient: INostrClient, private val indexerRelays: Set, private val localLookup: (HexKey) -> List?, + private val outboxLookup: (HexKey) -> List? = { null }, private val cacheTtlMs: Long = 60 * 60 * 1_000L, private val cacheSize: Int = 100, private val nowMs: () -> Long = { @@ -88,8 +97,17 @@ class DmInboxRelayResolver( private val mutex = Mutex() /** - * Resolve `pubkey`'s NIP-17 inbox relays. Returns empty list if neither - * the LocalCache nor the indexer fan-out yielded a kind:10050. + * Resolve `pubkey`'s NIP-17 inbox relays. Returns empty list if neither the + * LocalCache, the recipient's outbox relays, nor the indexer fan-out yielded + * a kind:10050. + * + * Two network phases against the unauthenticated client: + * 1. Query the curated indexers **plus** the recipient's known write relays + * (from a cached kind:10002) for kinds 10050/10002 in one shot. + * 2. If that still produced no kind:10050 but we now know the recipient's + * write relays (from the kind:10002 the indexers returned, or the cache), + * read kind:10050 directly from those write relays — the canonical + * NIP-65 outbox location, which the curated indexers may not mirror. */ suspend fun resolve(pubkey: HexKey): List { localLookup(pubkey)?.takeIf { it.isNotEmpty() }?.let { return it } @@ -108,12 +126,31 @@ class DmInboxRelayResolver( } } - if (indexerRelays.isEmpty()) return emptyList() + val cachedOutbox = outboxLookup(pubkey)?.toSet().orEmpty() + val phase1Seed = indexerRelays + cachedOutbox + if (phase1Seed.isEmpty()) return emptyList() - val lists = RecipientRelayFetcher.fetchRelayLists(unauthenticatedClient, pubkey, indexerRelays) + // Phase 1: indexers ∪ known outbox relays. + val lists = RecipientRelayFetcher.fetchRelayLists(unauthenticatedClient, pubkey, phase1Seed) // Strict: kind:10050 ONLY. No NIP-65 fallback. Empty = canonical // "unreachable" signal; caller refuses to publish. - val relays = lists.dmInbox + var relays = lists.dmInbox + + // Phase 2: the recipient's own write relays are where their kind:10050 + // lives per NIP-65. If phase 1 (mostly the curated indexers) didn't + // surface it, read straight from the write relays the indexers' + // kind:10002 just pointed us at — minus what phase 1 already queried + // (which already includes any cached outbox relays). Bounded by a + // shorter timeout so a cold send doesn't stack two full fan-out waits. + if (relays.isEmpty()) { + val writeRelays = lists.nip65Write().toSet() - phase1Seed + if (writeRelays.isNotEmpty()) { + relays = + RecipientRelayFetcher + .fetchRelayLists(unauthenticatedClient, pubkey, writeRelays, timeoutMs = 5_000L) + .dmInbox + } + } mutex.withLock { cache[pubkey] = Entry(relays, now + cacheTtlMs) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt index b8c1378006..2f5cbee961 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt @@ -90,4 +90,33 @@ class NoteIsHiddenForTest { assertTrue(note.isHiddenFor(choices), "Note whose author is hidden must still be hidden") } + + private fun tTag(hashtag: String) = arrayOf("t", hashtag) + + @Test + fun note_withMutedHashtag_isHidden() { + val event = textNoteEvent(id = replyId, eTags = arrayOf(tTag("bitcoin"))) + val note = Note(replyId).also { it.event = event } + val choices = noHidden.copy(hiddenHashtags = setOf("bitcoin")) + + assertTrue(note.isHiddenFor(choices), "Note tagged with a muted hashtag must be hidden") + } + + @Test + fun note_withMutedHashtag_isCaseInsensitive() { + val event = textNoteEvent(id = replyId, eTags = arrayOf(tTag("Bitcoin"))) + val note = Note(replyId).also { it.event = event } + val choices = noHidden.copy(hiddenHashtags = setOf("bitcoin")) + + assertTrue(note.isHiddenFor(choices), "Muted-hashtag matching must ignore case") + } + + @Test + fun note_withoutMutedHashtag_isNotHidden() { + val event = textNoteEvent(id = replyId, eTags = arrayOf(tTag("nostr"))) + val note = Note(replyId).also { it.event = event } + val choices = noHidden.copy(hiddenHashtags = setOf("bitcoin")) + + assertFalse(note.isHiddenFor(choices), "Note without the muted hashtag must not be hidden") + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequestsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequestsTest.kt new file mode 100644 index 0000000000..1f8a821be1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequestsTest.kt @@ -0,0 +1,121 @@ +/* + * 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.commons.relayClient.auth + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AuthApprovalRequestsTest { + private val ownInbox = NormalizedRelayUrl("wss://my.inbox.relay/") + private val someOtherRelay = NormalizedRelayUrl("wss://random.relay/") + + private fun pendingFor(relay: NormalizedRelayUrl): CompletableDeferred { + val deferred = CompletableDeferred() + return deferred + } + + /** + * Reproduces the cold-boot tier-1 race. + * + * A relay challenges for AUTH before the account's kind:10050 has loaded, + * so it is surfaced as a tier-2 pending prompt. When kind:10050 finally + * loads and the relay turns out to be the user's own DM inbox (tier-1), the + * spurious prompt must clear and the suspended signer must be released to + * sign — without persisting anything (ONCE), because it is trusted by + * identity, not by an explicit user grant. + * + * RED until [AuthApprovalRequests.autoApproveNowTrusted] is implemented. + */ + @Test + fun pendingChallengeForNowTrustedRelayIsAutoApproved() = + runTest { + val requests = AuthApprovalRequests() + val deferred = pendingFor(ownInbox) + requests.add(PendingAuthApproval(ownInbox, deferred)) + assertTrue(requests.pending.value.containsKey(ownInbox)) + + // kind:10050 arrives: the challenging relay is now tier-1. + requests.autoApproveNowTrusted(setOf(ownInbox)) + + assertFalse( + requests.pending.value.containsKey(ownInbox), + "spurious banner must clear once the relay is known to be trusted", + ) + assertTrue(deferred.isCompleted, "the suspended signer must be released") + assertEquals( + AuthApprovalScope.ONCE, + deferred.await(), + "tier-1 auto-approval must sign but NOT persist (ONCE)", + ) + } + + @Test + fun pendingChallengeForUntrustedRelayIsLeftForTheUser() = + runTest { + val requests = AuthApprovalRequests() + val deferred = pendingFor(someOtherRelay) + requests.add(PendingAuthApproval(someOtherRelay, deferred)) + + // A different relay became trusted; this one is still unknown. + requests.autoApproveNowTrusted(setOf(ownInbox)) + + assertTrue( + requests.pending.value.containsKey(someOtherRelay), + "an untrusted relay's prompt must remain for the user to decide", + ) + assertFalse(deferred.isCompleted, "the untrusted signer must stay suspended") + } + + @Test + fun resolveRemovesEntryAndCompletesDeferred() = + runTest { + val requests = AuthApprovalRequests() + val deferred = pendingFor(someOtherRelay) + requests.add(PendingAuthApproval(someOtherRelay, deferred)) + + assertTrue(requests.resolve(someOtherRelay, AuthApprovalScope.BLOCKED)) + + assertFalse(requests.pending.value.containsKey(someOtherRelay)) + assertEquals(AuthApprovalScope.BLOCKED, deferred.await()) + assertFalse(requests.resolve(someOtherRelay, AuthApprovalScope.ONCE), "second resolve is a no-op") + } + + @Test + fun cancelAllReleasesEverySignerWithBlocked() = + runTest { + val requests = AuthApprovalRequests() + val a = pendingFor(ownInbox) + val b = pendingFor(someOtherRelay) + requests.add(PendingAuthApproval(ownInbox, a)) + requests.add(PendingAuthApproval(someOtherRelay, b)) + + requests.cancelAll() + + assertTrue(requests.pending.value.isEmpty()) + assertEquals(AuthApprovalScope.BLOCKED, a.await()) + assertEquals(AuthApprovalScope.BLOCKED, b.await()) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelLastNoteTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelLastNoteTest.kt new file mode 100644 index 0000000000..3505bc2fa9 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelLastNoteTest.kt @@ -0,0 +1,93 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Regression cover for the Concord attach-before-consume ordering: `LocalCache.consumeConcordRumor` + * attaches a message row to its [ConcordChannel] (so the note carries its gatherer through the + * Messages filter) *before* `justConsume` loads the event, so [com.vitorpamplona.amethyst.commons.model.Channel.addNote] + * runs while `createdAt()` is null and cannot pick [com.vitorpamplona.amethyst.commons.model.Channel.lastNote]. + * Without the [com.vitorpamplona.amethyst.commons.model.Channel.refreshAfterEventLoad] follow-up, + * `lastNote` stays null forever and every channel row renders "No messages yet" even after messages + * have loaded. + */ +class ConcordChannelLastNoteTest { + private val community = "aa".repeat(32) + private val channelId = "cc".repeat(32) + private val author = "bb".repeat(32) + + private fun channel() = ConcordChannel(ConcordChannelId(community, channelId)) + + private fun event(createdAt: Long): Event = EventFactory.create("00".repeat(32), author, createdAt, 1, arrayOf(), "hi", "22".repeat(64)) + + @Test + fun lastNoteStaysNullUntilEventLoads_thenReflectsIt() { + val c = channel() + val note = Note("11".repeat(32)) + + // Attach exactly as Concord does — before the rumor is consumed, so the event is still null. + c.addNote(note) + assertNull(c.lastNote, "addNote can't pick lastNote while createdAt() is null (the bug)") + + // justConsume loads the event; the follow-up refresh must now set lastNote. + note.event = event(1000) + c.refreshAfterEventLoad(note) + assertEquals(note, c.lastNote) + } + + @Test + fun refreshKeepsTheNewestAndIgnoresLaterOlderLoads() { + val c = channel() + val older = Note("11".repeat(32)) + val newer = Note("22".repeat(32)) + c.addNote(older) + c.addNote(newer) + + older.event = event(1000) + c.refreshAfterEventLoad(older) + newer.event = event(2000) + c.refreshAfterEventLoad(newer) + assertEquals(newer, c.lastNote) + + // An out-of-order older message loading afterwards must not overwrite the newest. + val evenOlder = Note("33".repeat(32)) + c.addNote(evenOlder) + evenOlder.event = event(500) + c.refreshAfterEventLoad(evenOlder) + assertEquals(newer, c.lastNote) + } + + @Test + fun refreshIsANoOpForANoteNotInThisChannel() { + val c = channel() + val stray = Note("44".repeat(32)).apply { event = event(9999) } + c.refreshAfterEventLoad(stray) + assertNull(c.lastNote) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt index d346cfaabf..6d54bf43bb 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.utils.EventFactory import kotlin.test.Test import kotlin.test.assertEquals @@ -79,6 +80,70 @@ class RelayGroupChannelTest { return EventFactory.create("00".repeat(32), relaySelf, createdAt, GroupAdminsEvent.KIND, tags, "", "22".repeat(64)) as GroupAdminsEvent } + private val msg1 = "11".repeat(32) + private val msg2 = "22".repeat(32) + private val msg3 = "33".repeat(32) + + private fun pinned( + createdAt: Long, + vararg eventIds: String, + ): GroupPinnedEvent { + val tags = (listOf(arrayOf("d", gid)) + eventIds.map { arrayOf("e", it) }).toTypedArray() + return EventFactory.create("00".repeat(32), relaySelf, createdAt, GroupPinnedEvent.KIND, tags, "", "22".repeat(64)) as GroupPinnedEvent + } + + @Test + fun pinnedListFoldsInOrderAndReportsMembership() { + val c = channel() + assertTrue(c.pinnedEventIds.isEmpty()) + assertFalse(c.isPinned(msg1)) + + c.updatePinned(pinned(100, msg1, msg2)) + + assertEquals(listOf(msg1, msg2), c.pinnedEventIds) + assertTrue(c.isPinned(msg1)) + assertTrue(c.isPinned(msg2)) + assertFalse(c.isPinned(msg3)) + } + + @Test + fun pinListIsFullyReplacedByNewerEvent() { + val c = channel() + c.updatePinned(pinned(100, msg1, msg2)) + // A newer list drops msg1, keeps msg2, adds msg3 — the whole set is replaced. + c.updatePinned(pinned(200, msg2, msg3)) + + assertEquals(listOf(msg2, msg3), c.pinnedEventIds) + assertFalse(c.isPinned(msg1)) + assertTrue(c.isPinned(msg3)) + } + + @Test + fun clearingPinsWithEmptyNewerList() { + val c = channel() + c.updatePinned(pinned(100, msg1)) + c.updatePinned(pinned(200)) + assertTrue(c.pinnedEventIds.isEmpty()) + assertFalse(c.isPinned(msg1)) + } + + @Test + fun stalePinEventDoesNotSupersede() { + val c = channel() + c.updatePinned(pinned(200, msg1, msg2)) + // An OLDER pin list arrives late — ignore it. + c.updatePinned(pinned(100, msg3)) + assertEquals(listOf(msg1, msg2), c.pinnedEventIds) + } + + @Test + fun equalCreatedAtPinDoesNotResupersede() { + val c = channel() + c.updatePinned(pinned(100, msg1, msg2)) + c.updatePinned(pinned(100, msg3)) + assertEquals(listOf(msg1, msg2), c.pinnedEventIds) + } + @Test fun placeholderNoteCarriesChannelGathererAndIsStable() { val c = channel() diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt new file mode 100644 index 0000000000..1e22c715f1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt @@ -0,0 +1,154 @@ +/* + * 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.commons.relayClient.nip17Dm + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Covers the NIP-65 outbox path of [DmInboxRelayResolver.resolve]: when the + * curated indexers do NOT carry the recipient's kind:10050 but DO know their + * kind:10002, the resolver must read the 10050 straight from the recipient's + * declared write (outbox) relays. + */ +class DmInboxRelayResolverOutboxTest { + private val peer: HexKey = "a".repeat(64) + + private val indexer = NormalizedRelayUrl("wss://indexer.test/") + private val peerOutbox = NormalizedRelayUrl("wss://peer-write.test/") + private val peerDmInbox = NormalizedRelayUrl("wss://peer-dm.test/") + + private fun dummySig() = "0".repeat(128) + + private fun outbox10002(write: List) = + AdvertisedRelayListEvent( + id = "b".repeat(64), + pubKey = peer, + createdAt = 1_700_000_000, + tags = write.map { arrayOf("r", it.url, "write") }.toTypedArray(), + content = "", + sig = dummySig(), + ) + + private fun dm10050(inbox: List) = + ChatMessageRelayListEvent( + id = "c".repeat(64), + pubKey = peer, + createdAt = 1_700_000_050, + tags = ChatMessageRelayListEvent.createTagArray(inbox), + content = "", + sig = dummySig(), + ) + + /** + * Replays scripted events per (kind, relay) and auto-EOSEs on subscribe — + * enough for [fetchAll] which awaits one EOSE per relay. + */ + private class ScriptedClient( + delegate: INostrClient = EmptyNostrClient(), + ) : INostrClient by delegate { + private val script = mutableMapOf, List>() + val queriedRelays = mutableSetOf() + + fun scriptEvent( + kind: Int, + relay: NormalizedRelayUrl, + events: List, + ) { + script[kind to relay] = events + } + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + filters.forEach { (relay, filterList) -> + queriedRelays.add(relay) + filterList.forEach { filter -> + filter.kinds?.forEach { kind -> + script[kind to relay]?.forEach { event -> + listener?.onEvent(event, isLive = false, relay = relay, forFilters = null) + } + } + } + listener?.onEose(relay, forFilters = null) + } + } + + override fun unsubscribe(subId: String) { /* no-op */ } + } + + @Test + fun `reads kind 10050 from the recipient write relays when indexers only have their 10002`() = + runBlocking { + val client = ScriptedClient() + // Indexer knows WHERE the peer writes (10002) but not their 10050. + client.scriptEvent(AdvertisedRelayListEvent.KIND, indexer, listOf(outbox10002(listOf(peerOutbox)))) + // The peer's own write relay serves their 10050. + client.scriptEvent(ChatMessageRelayListEvent.KIND, peerOutbox, listOf(dm10050(listOf(peerDmInbox)))) + + val resolver = + DmInboxRelayResolver( + unauthenticatedClient = client, + indexerRelays = setOf(indexer), + localLookup = { null }, + outboxLookup = { null }, + ) + + val result = resolver.resolve(peer) + + assertEquals(listOf(peerDmInbox), result) + assertTrue("must read 10050 from the peer's own write relay", peerOutbox in client.queriedRelays) + } + + @Test + fun `uses a cached outbox as a phase-1 seed`() = + runBlocking { + val client = ScriptedClient() + // No 10002 on the indexer; the write relay is only known from cache. + client.scriptEvent(ChatMessageRelayListEvent.KIND, peerOutbox, listOf(dm10050(listOf(peerDmInbox)))) + + val resolver = + DmInboxRelayResolver( + unauthenticatedClient = client, + indexerRelays = setOf(indexer), + localLookup = { null }, + outboxLookup = { listOf(peerOutbox) }, + ) + + val result = resolver.resolve(peer) + + assertEquals(listOf(peerDmInbox), result) + assertTrue("cached outbox relay must be queried in phase 1", peerOutbox in client.queriedRelays) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index df3bb92416..cd96d371e6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -93,19 +93,6 @@ object DesktopPreferences { prefs.put(KEY_PINNED_NAV_ITEMS, value) } - private const val KEY_BLOSSOM_SERVERS = "blossom_servers" - private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" - - var blossomServers: List - get() { - val raw = prefs.get(KEY_BLOSSOM_SERVERS, DEFAULT_BLOSSOM_SERVER) - return if (raw.isBlank()) emptyList() else raw.split(",") - } - set(value) = prefs.put(KEY_BLOSSOM_SERVERS, value.joinToString(",")) - - val preferredBlossomServer: String - get() = blossomServers.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER - private const val KEY_SIDEBAR_COLLAPSED = "sidebar_collapsed" var sidebarCollapsed: Boolean diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 90c52eeffe..ec33ae1ff2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.auth.DesktopAuthCoordinator import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories @@ -111,6 +112,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen import com.vitorpamplona.amethyst.desktop.ui.ImportFollowListDialog +import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog @@ -148,6 +150,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.LogLevel import kotlinx.collections.immutable.toPersistentMap @@ -1019,6 +1022,14 @@ private fun AppInner( // metadata to relays the recipient never designated for DMs. localCache.getUserIfExists(pubkey)?.dmInboxRelaysStrict() }, + outboxLookup = { pubkey -> + // The recipient's kind:10050 lives on their NIP-65 write + // relays. When we've already cached their kind:10002 (via the + // feed / outbox dispatcher), hand those write relays to the + // resolver so it reads the 10050 from the source instead of + // relying on the curated indexers to have mirrored it. + localCache.cachedAdvertisedRelayList(pubkey)?.writeRelaysNorm() + }, ) } @@ -1050,7 +1061,15 @@ private fun AppInner( scope = scope, indexRelays = indexRelaysStore.effective(), localCache = localCache, - ).also { it.startCleanupLoop() } + ).also { coordinator -> + coordinator.startCleanupLoop() + // Whenever we ingest a user's kind:0 profile, back-fill their + // kind:10002 so their outbox (write) relays — where their + // kind:10050 and other replaceables live — are known. + localCache.onProfileMetadataConsumed = { pubkey -> + coordinator.ensureOutboxRelayList(pubkey) + } + } } // NIP-42 AUTH coordinator — wires relay-auth challenges through the @@ -1339,6 +1358,34 @@ private fun AppInner( val account = accountState as AccountState.LoggedIn val nwcConnection by accountManager.nwcConnection.collectAsState() + // Account state holders (relay lists, blossom servers, DMs, WoT). + // Hoisted above MainContent so the top-level compose dialog can also + // read the account's blossom server list from iAccount directly. + val dmSendTracker = remember(relayManager) { DmSendTracker(relayManager.client) } + // Created before iAccount so NIP-65 backup can be loaded. + val accountRelays = + remember(account, relayManager, scope) { + DesktopAccountRelays(account.pubKeyHex, relayManager, scope) + } + // Cold-boot AUTH race fix: when the account's own kind:10050 DM-inbox + // set loads (often AFTER an inbox relay has already challenged for + // AUTH), retroactively auto-approve any pending tier-2 prompt for a + // relay that is actually tier-1, instead of leaving a spurious banner. + LaunchedEffect(authCoordinator, accountRelays) { + accountRelays.dmRelayList.collect { dmInbox -> + authCoordinator.onSelfApprovedRelaysChanged(dmInbox) + } + } + val iAccount = + remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) { + DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver) + } + // When iAccount is replaced (account switch), close the previous + // WoTService so its writer coroutine + ops Channel don't leak. + DisposableEffect(iAccount) { + onDispose { iAccount.wotService.close() } + } + // Lazy-load Namecoin services. The Core RPC HTTP // client is sourced from the Tor-aware DesktopHttpClient // singleton so .onion RPC URLs route through the @@ -1412,6 +1459,9 @@ private fun AppInner( localCache = localCache, accountManager = accountManager, account = account, + iAccount = iAccount, + accountRelays = accountRelays, + dmSendTracker = dmSendTracker, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, indexRelaysStore = indexRelaysStore, @@ -1447,18 +1497,23 @@ private fun AppInner( } } - // Compose dialog + // Compose dialog. Hosted outside MainContent's provider, + // so provide the account's blossom list here too. if (showComposeDialog) { - ComposeNoteDialog( - onDismiss = onDismissComposeDialog, - relayManager = relayManager, - account = account, - localCache = localCache, - replyTo = replyToNote, - draftDTag = composeEditDraftTag, - draftInitialContent = composeEditContent, - initialScheduledForSec = composeEditScheduledForSec, - ) + CompositionLocalProvider( + LocalBlossomServers provides iAccount.blossomServerList.flow, + ) { + ComposeNoteDialog( + onDismiss = onDismissComposeDialog, + relayManager = relayManager, + account = account, + localCache = localCache, + replyTo = replyToNote, + draftDTag = composeEditDraftTag, + draftInitialContent = composeEditContent, + initialScheduledForSec = composeEditScheduledForSec, + ) + } } // App Drawer overlay @@ -1542,6 +1597,9 @@ fun MainContent( localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + iAccount: DesktopIAccount, + accountRelays: DesktopAccountRelays, + dmSendTracker: DmSendTracker, nwcConnection: Nip47WalletConnect.Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, indexRelaysStore: com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays, @@ -1561,31 +1619,6 @@ fun MainContent( val signerConnectionState by accountManager.signerConnectionState.collectAsState() val lastPingTimeSec by accountManager.lastPingTimeSec.collectAsState() - // DM infrastructure — hoisted here so it survives screen navigation - val dmSendTracker = - remember(relayManager) { - DmSendTracker(relayManager.client) - } - // Centralized relay state for all categories (DM, search, blocked, NIP-65 persistence) - // Created before iAccount so NIP-65 backup can be loaded - val accountRelays = - remember(account, relayManager, scope) { - DesktopAccountRelays(account.pubKeyHex, relayManager, scope) - } - - val iAccount = - remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) { - DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver) - } - - // When iAccount is replaced (account switch), the previous WoTService's - // internal writer coroutine + ops Channel would otherwise leak — the - // outer `scope` lives for the whole session. Close the previous - // instance on dispose so account-switch is a clean teardown. - DisposableEffect(iAccount) { - onDispose { iAccount.wotService.close() } - } - // Follow Packs state — single per-account holder for Discover + sidebar + naddr cards val followPacksState = remember(iAccount, localCache, relayManager, scope) { @@ -1687,9 +1720,10 @@ fun MainContent( ChatMessageRelayListEvent.KIND, SearchRelayListEvent.KIND, BlockedRelayListEvent.KIND, + BlossomServersEvent.KIND, ), authors = listOf(account.pubKeyHex), - limit = 4, + limit = 5, ) relayManager.subscribe( subId = bootstrapSubId, @@ -1702,9 +1736,17 @@ fun MainContent( relay: NormalizedRelayUrl, forFilters: List?, ) { - // NIP-65 (kind 10002) must go through justConsumeMyOwnEvent - // because localCache.consume() doesn't handle addressable events - if (event is AdvertisedRelayListEvent) { + // NIP-65 (kind 10002), the NIP-17 DM relay list (kind + // 10050) and the Blossom server list (kind 10063) are + // addressable/replaceable events; route them through + // justConsumeMyOwnEvent so they land in the addressable- + // note cache the User model / state holders observe (e.g. + // dmInboxRelaysStrict, self-copy resolution) alongside + // accountRelays' persisted copy. + if (event is AdvertisedRelayListEvent || + event is ChatMessageRelayListEvent || + event is BlossomServersEvent + ) { scope.launch(Dispatchers.IO) { localCache.justConsumeMyOwnEvent(event) } @@ -1717,6 +1759,52 @@ fun MainContent( onDispose { relayManager.unsubscribe(bootstrapSubId) } } + // The bootstrap subscription above only reaches the default relays. A user's + // own account data — Blossom server list (kind 10063), DM/search/blocked + // relay lists — is published to their write relays, not the defaults, so it + // won't be found there. Re-fetch those kinds from the NIP-65 outbox + // (write + untagged relays) whenever it becomes known. + LaunchedEffect(iAccount) { + iAccount.nip65RelayList.outboxFlow.collect { outbox -> + if (outbox.isEmpty()) return@collect + relayManager.subscribe( + subId = "account-config-outbox", + filters = + listOf( + Filter( + kinds = + listOf( + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + BlockedRelayListEvent.KIND, + BlossomServersEvent.KIND, + ), + authors = listOf(account.pubKeyHex), + limit = 10, + ), + ), + relays = outbox, + listener = + object : SubscriptionListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is AdvertisedRelayListEvent || event is BlossomServersEvent) { + scope.launch(Dispatchers.IO) { + localCache.justConsumeMyOwnEvent(event) + } + } + accountRelays.consumeIfRelevant(event) + } + }, + ) + } + } + // Subscribe to incoming DMs and process into chatroomList LaunchedEffect(account) { relayManager.connectedRelays.first { it.isNotEmpty() } @@ -1966,6 +2054,7 @@ fun MainContent( CompositionLocalProvider( LocalRelayCategories provides relayCategories, + LocalBlossomServers provides iAccount.blossomServerList.flow, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, @@ -2219,6 +2308,7 @@ fun RelaySettingsScreen( .TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF), onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {}, namecoinPreferences: DesktopNamecoinPreferences? = null, + onBlossomServersChanged: (List) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() @@ -2342,11 +2432,17 @@ fun RelaySettingsScreen( HorizontalDivider() Spacer(Modifier.height(24.dp)) - // Media Server Settings - MediaServerSettings( - initialServers = DesktopPreferences.blossomServers, - onServersChanged = { DesktopPreferences.blossomServers = it }, - ) + // Media Server Settings (Blossom, kind 10063 — synced with mobile) + val networkBlossomServers by (LocalBlossomServers.current?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) + // The kind-10063 list is authoritative; before it loads (or when the + // user has published none) show the default server. + val effectiveBlossomServers = networkBlossomServers.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } + key(effectiveBlossomServers) { + MediaServerSettings( + initialServers = effectiveBlossomServers, + onServersChanged = onBlossomServersChanged, + ) + } Spacer(Modifier.height(24.dp)) HorizontalDivider() Spacer(Modifier.height(24.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt index fa06c42d16..f00fe83cc7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.auth import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalDecision import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalPolicy +import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalRequests import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalStore import com.vitorpamplona.amethyst.commons.relayClient.auth.PendingAuthApproval @@ -34,12 +35,8 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.PersistentMap -import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update /** * Desktop NIP-42 AUTH wiring. @@ -79,13 +76,13 @@ class DesktopAuthCoordinator( @Volatile private var active: ActiveAuth? = null - private val _pendingApprovals = MutableStateFlow>(persistentMapOf()) + private val requests = AuthApprovalRequests() /** * Tier-2 AUTH challenges awaiting the user's `[Once] [Always] [Never]` * decision. The banner UI subscribes and calls [resolve] to settle each. */ - val pendingApprovals: StateFlow> = _pendingApprovals.asStateFlow() + val pendingApprovals: StateFlow> = requests.pending /** Wire AUTH for a newly logged-in account. Idempotent. */ fun onLogin(account: AccountState.LoggedIn) { @@ -97,9 +94,7 @@ class DesktopAuthCoordinator( AuthApprovalPolicy( selfApprovedRelays = { selfApprovedRelaysFor(account.pubKeyHex) }, store = store, - onPromptRequired = { pending -> - _pendingApprovals.update { it.put(pending.relayUrl, pending) } - }, + onPromptRequired = { pending -> requests.add(pending) }, ) val authenticator = RelayAuthenticator( @@ -121,26 +116,35 @@ class DesktopAuthCoordinator( } /** - * Resolve a tier-2 [PendingAuthApproval] from the banner UI. - * - * Removes the entry from [pendingApprovals] before completing the - * deferred, so the suspended signer wakes up exactly once. + * Resolve a tier-2 [PendingAuthApproval] from the banner UI. The suspended + * signer wakes up exactly once with the user's pick. */ fun resolve( relayUrl: NormalizedRelayUrl, scope: AuthApprovalScope, ) { - val pending = _pendingApprovals.value[relayUrl] ?: return - _pendingApprovals.update { it.remove(relayUrl) } - pending.decision.complete(scope) + requests.resolve(relayUrl, scope) + } + + /** + * Called when the active account's NIP-17 DM-inbox (kind:10050) set loads + * or changes. Auto-approves any pending tier-2 prompt whose relay is now in + * that set, fixing the cold-boot race where an own inbox relay challenges + * for AUTH before kind:10050 has been fetched and gets a spurious prompt + * that nothing would otherwise re-evaluate. + * + * [trusted] must be the strict kind:10050 inbox set (same tier-1 source as + * [selfApprovedRelaysFor]) — never the lenient NIP-65 fallback. + */ + fun onSelfApprovedRelaysChanged(trusted: Set) { + requests.autoApproveNowTrusted(trusted) } private fun tearDownLocked() { val prev = active ?: return prev.authenticator.destroy() // Cancel any in-flight tier-2 prompts so suspended signers wake up. - _pendingApprovals.value.values.forEach { it.decision.complete(AuthApprovalScope.BLOCKED) } - _pendingApprovals.value = persistentMapOf() + requests.cancelAll() active = null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt index ff47f1d38d..e059bc9dbc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt @@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.desktop.auth import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalStore +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.sha256.sha256 import java.util.prefs.Preferences /** @@ -46,6 +48,16 @@ import java.util.prefs.Preferences * `ONCE` scope is never persisted — that's the in-memory contract enforced * by the [AuthApprovalStore] interface. This implementation only writes * `ALWAYS` and `BLOCKED`. + * + * **Key length:** `java.util.prefs.Preferences` caps keys at + * [Preferences.MAX_KEY_LENGTH] (80 chars) and throws `IllegalArgumentException` + * from [Preferences.put] for anything longer. Relay URLs routinely exceed that + * — e.g. an outbox-proxy URL that embeds an npub and a query string + * (`wss://filter.nostr.wine/npub1…?broadcast=true`, 100+ chars). Storing such a + * URL raw made [setScope] throw; the caller (`RelayAuthenticator`) swallows the + * exception, so the `ALWAYS` / `BLOCKED` grant was silently never persisted and + * the AUTH banner re-appeared on every challenge. [keyFor] folds any over-long + * URL into a bounded 64-char SHA-256 hex key to stay under the cap. */ class PreferencesAuthApprovalStore( private val accountPubKeyHex: String, @@ -56,7 +68,7 @@ class PreferencesAuthApprovalStore( ) override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? { - val raw = node.get(relayUrl.url, null) ?: return null + val raw = node.get(authApprovalPreferenceKey(relayUrl), null) ?: return null return runCatching { AuthApprovalScope.valueOf(raw) }.getOrNull() } @@ -70,7 +82,7 @@ class PreferencesAuthApprovalStore( // upgrade to "until next clear()". return } - node.put(relayUrl.url, scope.name) + node.put(authApprovalPreferenceKey(relayUrl), scope.name) node.flush() } @@ -79,3 +91,25 @@ class PreferencesAuthApprovalStore( node.flush() } } + +/** + * The Preferences key for a relay URL, guaranteed to fit within + * [Preferences.MAX_KEY_LENGTH]. + * + * Short URLs are stored verbatim (readable, and backward-compatible with grants + * written before this fix). URLs over the limit are hashed to a + * `sha256:`-prefixed 64-char hex digest (71 chars total, under the 80 cap). The + * prefix keeps the hashed keyspace disjoint from raw relay URLs, which always + * start with `ws://` / `wss://`, so the two can never collide. + * + * Pure and side-effect-free so it can be unit-tested without touching the OS + * Preferences backing store. + */ +internal fun authApprovalPreferenceKey(relayUrl: NormalizedRelayUrl): String { + val url = relayUrl.url + return if (url.length <= Preferences.MAX_KEY_LENGTH) { + url + } else { + "sha256:" + sha256(url.encodeToByteArray()).toHexKey() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index fc762421e9..d3ea5999fc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.QAddressableTag @@ -57,6 +58,7 @@ import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -141,12 +143,16 @@ class DesktopLocalCache : ICacheProvider { override fun getOrCreateUser(pubkey: HexKey): User = users.getOrCreate(pubkey) { User(pubkey, userContext) } /** - * [UserContext] bridge — desktop's note store is keyed on string ids - * rather than full addressable maps, so we synthesise a stable id - * from the Address. User's lazy fields hold the resulting Note for - * its lifetime, same pinning guarantee as on Android. + * [UserContext] bridge — resolves the [User]'s pinned replaceable notes + * (kind:10002 outbox, kind:10050 DM inbox, kind:10019 nutzap info) against + * the same [addressableNotes] map that [consume] writes into. Backing these + * with [getOrCreateAddressableNote] (not the plain [notes] map) is what lets + * `User.dmInboxRelaysStrict()` / `outboxRelays()` actually reflect events the + * cache has ingested — the DM send path, the resolver's local lookup, and + * tier-1 AUTH all read those accessors. User's lazy fields hold the + * resulting note for its lifetime, same pinning guarantee as on Android. */ - private val userContext = UserContext { addr -> getOrCreateNote(addr.toValue()) } + private val userContext = UserContext { addr -> getOrCreateAddressableNote(addr) } override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { key, user -> predicate(key, user) } @@ -194,6 +200,16 @@ class DesktopLocalCache : ICacheProvider { ).take(limit) } + /** + * Invoked (with the author's pubkey) every time a kind:0 profile is + * ingested. Wired by the subscriptions coordinator to back-fill that + * author's kind:10002 (NIP-65 outbox) — so whenever we learn who a user + * is, we also learn where they write, which is where their kind:10050 and + * other replaceables live. The handler dedupes and skips authors whose + * 10002 is already cached, so firing on every kind:0 is cheap. + */ + var onProfileMetadataConsumed: ((HexKey) -> Unit)? = null + /** * Updates user metadata from a MetadataEvent. * Called when receiving kind 0 events from relays. @@ -214,6 +230,10 @@ class DesktopLocalCache : ICacheProvider { } } } + + // Learn where this author writes (kind:10002) so their replaceables — + // kind:10050 included — can be read from their own outbox relays. + onProfileMetadataConsumed?.invoke(event.pubKey) } fun justVerify(event: Event): Boolean = @@ -309,11 +329,40 @@ class DesktopLocalCache : ICacheProvider { consumeAdvertisedRelayList(event, relay) } + is ChatMessageRelayListEvent -> { + consumeChatMessageRelayList(event, relay) + } + + is BlossomServersEvent -> { + consumeBlossomServerList(event, relay) + } + else -> { false } } + /** + * Consumes a kind 10063 (NIP-B7) Blossom media server list event. Stores + * the newest per-author copy in [addressableNotes] so state holders like + * [com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState] + * observe it via their flows. This is the same event the Amethyst mobile + * app uses for the media server list. Emits nothing to the event stream — + * the UI doesn't render kind 10063s directly. + */ + private fun consumeBlossomServerList( + event: BlossomServersEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val addressableNote = getOrCreateAddressableNote(event.address()) + val existing = addressableNote.event + if (existing != null && existing.createdAt >= event.createdAt) return false + val author = getOrCreateUser(event.pubKey) + addressableNote.loadEvent(event, author, emptyList()) + relay?.let { addressableNote.addRelay(it) } + return false + } + /** * Consumes a kind 10002 (NIP-65) advertised relay list event. Stores * the newest per-author copy in [addressableNotes] so the outbox @@ -334,6 +383,30 @@ class DesktopLocalCache : ICacheProvider { return false } + /** + * Consumes a kind 10050 (NIP-17) DM relay-list event. Stores the newest + * per-author copy in [addressableNotes] so [User.dmInboxRelaysStrict] and + * the DM send path can resolve a recipient's inbox without a fresh REQ. + * + * Without this branch, [route] dropped kind:10050 entirely — recipient DM + * relay lists arriving through the normal feed / DM subscriptions never + * landed in the cache, forcing every send to re-discover them via the + * indexer fan-out. Mirrors [consumeAdvertisedRelayList]; emits nothing to + * the event stream because the UI doesn't render kind 10050s directly. + */ + private fun consumeChatMessageRelayList( + event: ChatMessageRelayListEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val addressableNote = getOrCreateAddressableNote(event.address()) + val existing = addressableNote.event + if (existing != null && existing.createdAt >= event.createdAt) return false + val author = getOrCreateUser(event.pubKey) + addressableNote.loadEvent(event, author, emptyList()) + relay?.let { addressableNote.addRelay(it) } + return false + } + /** * Returns the cached kind-10002 event for [pubkey], if any. Used by the * outbox dispatcher to skip a Phase-1 REQ for authors whose write-relay diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt new file mode 100644 index 0000000000..d3a319be66 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt @@ -0,0 +1,29 @@ +/* + * 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.desktop.model + +/** + * Fallback media server used when the account has published no kind-10063 + * Blossom server list yet. The list itself is read reactively from the account's + * [com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState] + * (`iAccount.blossomServerList.flow`); this is only the empty-list default. + */ +const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index e85c610398..75079df07f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListRepository import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -56,7 +57,11 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import java.util.concurrent.ConcurrentHashMap /** * Desktop implementation of IAccount. @@ -85,6 +90,14 @@ class DesktopIAccount( val oldBookmarkState = OldBookmarkListState(signer, localCache, scope) val bookmarkState = BookmarkListState(signer, localCache, scope) + /** + * User's Blossom media server list (NIP-B7 / kind 10063). Loads from the + * same event kind the Amethyst mobile app uses, so a server list configured + * on mobile shows up here too. Backed by [localCache]; populated by the + * account-config subscription in Main.kt. + */ + val blossomServerList = BlossomServerListState(signer, localCache, scope) + val kind3FollowList = Kind3FollowListState( signer, @@ -297,6 +310,47 @@ class DesktopIAccount( return recipients.associateWith { resolveDmInboxRelaysStrictOrdered(it).firstOrNull() } } + // Peers whose kind:10050 we've already kicked off a prewarm for. Prewarm is + // best-effort and idempotent — once requested we don't re-request, because + // the resolver's own LRU (and, once a 10050 arrives, LocalCache) serves + // subsequent lookups. The send/pre-send paths call the resolver directly + // and are NOT gated by this set, so they always see fresh-within-TTL data. + private val prewarmedDmInboxKeys = ConcurrentHashMap.newKeySet() + + // Cap concurrent kind:10050 fan-outs so scrolling a long conversation list + // (or opening several rooms quickly) doesn't burst the indexer relays. + private val dmInboxPrewarmLimiter = Semaphore(4) + + /** + * Proactively resolve (and cache) the NIP-17 DM inbox relays (kind:10050) + * for [pubkeys], so the first message/reaction sent to any of them resolves + * from cache instead of paying an indexer round-trip, and the composer's + * "recipient has no DM relays" gate settles before the user starts typing. + * + * Reusable app-wide: any surface that displays a DM (the visible rows of the + * conversation list, an opened room, a future notification/preview) should + * call this so the relay list is downloaded before the user drops into the + * room. Viewport-scoped by design — callers pass only the peers they are + * actually showing, so a large conversation list only warms what's visible + * and warms more as the user scrolls. + */ + fun prewarmDmInboxRelays(pubkeys: Collection) { + val resolver = dmInboxResolver ?: return + for (pubkey in pubkeys) { + if (pubkey.length != 64 || !prewarmedDmInboxKeys.add(pubkey)) continue + scope.launch(Dispatchers.IO) { + dmInboxPrewarmLimiter.withPermit { + try { + resolver.resolve(pubkey) + } catch (_: Exception) { + // Best-effort prefetch; the send path resolves again on demand. + prewarmedDmInboxKeys.remove(pubkey) + } + } + } + } + } + private fun addEventToChatroom( event: com.vitorpamplona.quartz.nip01Core.core.Event, roomKey: com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 33afa1921d..9aff62678a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -162,6 +163,71 @@ class DesktopRelaySubscriptionsCoordinator( private val _lastEventAt = MutableStateFlow(null) val lastEventAt: StateFlow = _lastEventAt.asStateFlow() + // -- kind:10002 (NIP-65 outbox) back-fill for profiles we ingest -- + // Every author whose kind:0 we consume gets their kind:10002 fetched and + // cached, so the outbox model (and the DM inbox resolver) can read their + // replaceables — kind:10050 included — straight from their write relays. + // `outboxRequested` is a permanent per-session dedup: we ask once per + // author. `pendingOutbox` buffers authors between batched REQs. + private val outboxRequested = ConcurrentHashMap.newKeySet() + private val pendingOutbox = ConcurrentHashMap.newKeySet() + private var outboxBackfillJob: Job? = null + + /** + * Ensure we have (or have requested) [pubkey]'s kind:10002. No-op if it's + * already cached or already queued/asked. Safe to call on every kind:0 + * ingest — wire it to [DesktopLocalCache.onProfileMetadataConsumed]. + */ + fun ensureOutboxRelayList(pubkey: HexKey) { + if (localCache.cachedAdvertisedRelayList(pubkey) != null) return + if (!outboxRequested.add(pubkey)) return + pendingOutbox.add(pubkey) + scheduleOutboxBackfill() + } + + /** + * Drain [pendingOutbox] in batches of ≤100 authors, one REQ at a time, + * coalescing bursts with a short delay. kind:10002 lives on the index / + * discovery relays (they mirror it widely), so we query those; results go + * through [consumeEvent] → cache. + * + * `@Synchronized` + the `isActive` guard make this launch-once: concurrent + * callers from the consume path don't spawn duplicate drain jobs. The job + * loops until [pendingOutbox] is empty, so authors that arrive mid-fetch + * (or a burst larger than one 100-author batch) are picked up in the same + * run instead of stranded until the next profile happens to arrive. + */ + @Synchronized + private fun scheduleOutboxBackfill() { + if (outboxBackfillJob?.isActive == true) return + outboxBackfillJob = + scope.launch(Dispatchers.IO) { + // Coalesce a burst of profile arrivals into the first batch. + delay(500) + + while (pendingOutbox.isNotEmpty()) { + val batch = pendingOutbox.take(100).toList() + pendingOutbox.removeAll(batch.toSet()) + if (batch.isEmpty() || indexRelays.isEmpty()) continue + + val filter = + Filter( + kinds = listOf(AdvertisedRelayListEvent.KIND), + authors = batch, + limit = batch.size, + ) + // fetchAll closes on EOSE (bounded by the timeout) rather + // than holding the sub open for a fixed window. + val events = + client.fetchAll( + filters = indexRelays.associateWith { listOf(filter) }, + timeoutMs = 8.seconds.inWholeMilliseconds, + ) + events.forEach { consumeEvent(it, null) } + } + } + } + /** * Central event router — consumes an event into the cache and emits to event stream. * Called from relay onEvent callbacks. Non-blocking (launches on IO dispatcher). @@ -438,6 +504,10 @@ class DesktopRelaySubscriptionsCoordinator( outboxDispatcher.clear() rateLimiter.reset() cleanupJob?.cancel() + + outboxBackfillJob?.cancel() + outboxRequested.clear() + pendingOutbox.clear() } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index b98cbada5c..9eedb59385 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -74,9 +74,9 @@ import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadResult import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.util.deleteOrWarn -import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.ImageCompressionStore import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.drafts.LocalNoteDraftStore @@ -205,7 +205,17 @@ fun ComposeNoteDialog( val uploadTracker = remember { DesktopUploadTracker() } val uploadState by uploadTracker.state.collectAsState() val orchestrator = remember { UploadOrchestrator() } - var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) } + // Media servers come straight from the account's kind-10063 list holder + // (account.blossomServerList.flow), provided via LocalBlossomServers — + // reactively, no cache-poking, no prefs. + val serverList by (LocalBlossomServers.current?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) + val effectiveServers = serverList.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } + var selectedServer by remember { mutableStateOf(effectiveServers.first()) } + // If the list loads (or changes) after the dialog opens and the current pick + // is no longer in it, snap to the first available server. + LaunchedEffect(effectiveServers) { + if (selectedServer !in effectiveServers) selectedServer = effectiveServers.first() + } var postAsPicture by remember { mutableStateOf(false) } // Scheduling — when non-null the note is stored for later publication instead of @@ -576,6 +586,7 @@ fun ComposeNoteDialog( verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, ) { ServerSelector( + servers = effectiveServers, selectedServer = selectedServer, onServerSelected = { selectedServer = it }, ) @@ -826,10 +837,10 @@ private fun buildIMetaTags(results: List): List = @Composable private fun ServerSelector( + servers: List, selectedServer: String, onServerSelected: (String) -> Unit, ) { - val servers = DesktopPreferences.blossomServers if (servers.size <= 1) { // Only one server — just show label, no dropdown Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalBlossomServers.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalBlossomServers.kt new file mode 100644 index 0000000000..2cb0aab846 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalBlossomServers.kt @@ -0,0 +1,34 @@ +/* + * 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.desktop.ui + +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.flow.StateFlow + +/** + * The logged-in account's Blossom media server list (NIP-B7 / kind 10063), + * exposed as the reactive flow from the account's `BlossomServerListState` + * (`iAccount.blossomServerList.flow`). Provided once around the logged-in UI so + * upload composables read the list from context instead of threading it through + * every screen. `null` when no account is logged in — consumers fall back to + * [com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER]. + */ +val LocalBlossomServers = staticCompositionLocalOf>?> { null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index c746d64487..3720ed4a64 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -88,7 +88,8 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel -import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER +import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow @@ -101,6 +102,7 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.awt.datatransfer.DataFlavor import java.awt.dnd.DnDConstants @@ -141,6 +143,8 @@ fun ChatPane( modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() + // Account's Blossom media servers (kind 10063), read from context. + val blossomServers = LocalBlossomServers.current val feedState by feedViewModel.feedState.feedContent.collectAsState() val messageText by messageState.message.collectAsState() val recipientsMissingRelays by messageState.recipientsMissingDmRelays.collectAsState() @@ -379,6 +383,7 @@ fun ChatPane( roomKey = roomKey, account = account, cacheProvider = cacheProvider, + blossomServers = blossomServers, ) attachedFiles.clear() } catch (e: Exception) { @@ -880,9 +885,10 @@ private suspend fun sendEncryptedFiles( roomKey: ChatroomKey, account: IAccount, cacheProvider: ICacheProvider, + blossomServers: StateFlow>?, ) { val orchestrator = UploadOrchestrator() - val server = DesktopPreferences.preferredBlossomServer + val server = blossomServers?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) }.map { it.toPTag() } for (file in files) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt index fc7dd95cc8..d636037723 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.privateChats.Chatroom import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -195,6 +196,17 @@ class ChatroomListState( } } + /** + * Proactively download the NIP-17 DM relay lists (kind 10050) for the given + * conversation peers. Called from the list UI for the rows that are actually + * visible (and again as more scroll into view), so we only warm what the + * user is looking at rather than every conversation on disk. Delegates to + * the reusable, deduped [DesktopIAccount.prewarmDmInboxRelays]. + */ + fun prewarmPeerRelays(pubkeys: Collection) { + (account as? DesktopIAccount)?.prewarmDmInboxRelays(pubkeys) + } + private suspend fun refreshRooms() { val chatroomList = account.chatroomList val known = mutableListOf() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index 79c470c152..337b738e8b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -43,6 +43,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -258,6 +259,13 @@ fun ConversationListPane( modifier = Modifier.weight(1f), ) { items(currentList, key = { it.roomKey.hashCode() }) { item -> + // Viewport-driven prewarm: a LazyColumn only composes the + // rows that are visible (plus a small buffer), so this warms + // each peer's NIP-17 DM relay list exactly when their row + // scrolls into view — and no earlier. + LaunchedEffect(item.roomKey) { + state.prewarmPeerRelays(item.roomKey.users) + } val index = currentList.indexOf(item) val isFocused = index == focusedIndex ConversationCard( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 8823c6197f..a2668c14bf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -186,6 +186,7 @@ fun DesktopMessagesScreen( showNewDmDialog = false }, onDismiss = { showNewDmDialog = false }, + onPrewarmDmRelays = { (account as? DesktopIAccount)?.prewarmDmInboxRelays(it) }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt index cc6f77a3f5..08f9c6e014 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -75,6 +75,7 @@ fun NewDmDialog( localCache: DesktopLocalCache, onUserSelected: (ChatroomKey) -> Unit, onDismiss: () -> Unit, + onPrewarmDmRelays: (Collection) -> Unit = {}, ) { val scope = rememberCoroutineScope() // Recipients selected so far. One → 1:1 chat, more than one → group chat. @@ -82,7 +83,15 @@ fun NewDmDialog( fun toggle(user: User) { val existing = selected.firstOrNull { it.pubkeyHex == user.pubkeyHex } - if (existing != null) selected.remove(existing) else selected.add(user) + if (existing != null) { + selected.remove(existing) + } else { + selected.add(user) + // Selecting a recipient is a strong signal we're about to DM them — + // download their kind:10050 now so the relay list is ready by the + // time the composer opens. + onPrewarmDmRelays(listOf(user.pubkeyHex)) + } } fun isSelected(user: User) = selected.any { it.pubkeyHex == user.pubkeyHex } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 19f2a8248e..9503e2e0a8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.desktop.ui.scheduledposts.DraftsAndScheduledSc import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch class ColumnNavigationState { private val _stack = mutableStateListOf() @@ -501,6 +502,19 @@ internal fun RootContent( torSettings = torState.settings, onTorSettingsChanged = torState.onSettingsChanged, namecoinPreferences = LocalNamecoinPreferences.current, + onBlossomServersChanged = { servers -> + // Publish a kind-10063 event so the list syncs to every + // Amethyst client, then consume it locally so state updates + // immediately (mirrors the NIP-65 save flow above). Read-only + // accounts can't sign, so this is a no-op there. + if (iAccount.isWriteable()) { + appScope.launch { + val event = iAccount.blossomServerList.saveBlossomServersList(servers) + relayManager.broadcastToAll(event) + localCache.justConsumeMyOwnEvent(event) + } + } + }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index 883b01733a..3a9b606d44 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -79,9 +79,10 @@ import com.vitorpamplona.amethyst.commons.profile.EditProfileFields import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator -import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client @@ -171,7 +172,7 @@ fun EditProfileDialog( } val orchestrator = remember { UploadOrchestrator() } - val serverBaseUrl = DesktopPreferences.preferredBlossomServer + val serverBaseUrl = LocalBlossomServers.current?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER fun uploadFile( file: File, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt new file mode 100644 index 0000000000..b17d737b5c --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt @@ -0,0 +1,94 @@ +/* + * 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.desktop.auth + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import java.util.prefs.Preferences +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Regression tests for the AUTH-banner-repeats bug. + * + * `PreferencesAuthApprovalStore` used the raw relay URL as the + * `java.util.prefs.Preferences` key. Preferences caps keys at + * [Preferences.MAX_KEY_LENGTH] (80 chars) and `put()` throws + * `IllegalArgumentException` for anything longer. Outbox-proxy relay URLs that + * embed an npub and a query string routinely exceed that, so the `ALWAYS` / + * `BLOCKED` grant silently failed to persist and the banner re-appeared on + * every AUTH challenge. + * + * These assert the key-derivation invariant directly (no OS Preferences I/O, so + * they run deterministically on every platform): every key stays within the + * cap, which is exactly the condition `Preferences.put` enforces. + */ +class PreferencesAuthApprovalStoreTest { + // 102 chars — the outbox-proxy URL from the bug report, over the 80 cap. + private val longUrl = "wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true" + + @Test + fun shortUrlStoredVerbatim() { + val url = "wss://relay.example/" + assertEquals(url, authApprovalPreferenceKey(NormalizedRelayUrl(url))) + } + + @Test + fun overLongUrlIsHashedUnderTheCap() { + assertEquals(102, longUrl.length) + val key = authApprovalPreferenceKey(NormalizedRelayUrl(longUrl)) + + assertTrue(key.startsWith("sha256:"), "over-long URLs must fold to a hashed key") + assertTrue( + key.length <= Preferences.MAX_KEY_LENGTH, + "key length ${key.length} must stay within Preferences.MAX_KEY_LENGTH so put() never throws", + ) + } + + @Test + fun everyKeyStaysWithinThePreferencesCap() { + // Boundary + well over: whatever the URL length, the derived key must be + // storable (this is the invariant Preferences.put enforces). + listOf( + "wss://relay.example/", + "wss://" + "a".repeat(Preferences.MAX_KEY_LENGTH), // 86 chars + "wss://" + "a".repeat(500), + longUrl, + ).forEach { url -> + val key = authApprovalPreferenceKey(NormalizedRelayUrl(url)) + assertTrue( + key.length <= Preferences.MAX_KEY_LENGTH, + "key for a ${url.length}-char URL was ${key.length} chars, over the ${Preferences.MAX_KEY_LENGTH} cap", + ) + } + } + + @Test + fun distinctOverLongUrlsProduceDistinctKeys() { + val a = "wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true" + val b = "wss://filter.nostr.wine/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq?broadcast=true" + assertNotEquals( + authApprovalPreferenceKey(NormalizedRelayUrl(a)), + authApprovalPreferenceKey(NormalizedRelayUrl(b)), + ) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt new file mode 100644 index 0000000000..6cdb574b23 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt @@ -0,0 +1,99 @@ +/* + * 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.desktop.cache + +import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * The desktop app must load the user's media server list from the same event + * kind the Amethyst mobile app uses — the NIP-B7 [BlossomServersEvent] + * (kind 10063). These tests verify that an incoming kind-10063 event lands in + * [DesktopLocalCache] and that the shared [BlossomServerListState] surfaces the + * declared servers. + */ +class DesktopBlossomServerListTest { + private val relayUrl = NormalizedRelayUrl("wss://relay.test/") + + private suspend fun signedServerList( + servers: List, + signer: NostrSignerInternal, + createdAt: Long = 1_700_000_000, + ): BlossomServersEvent = BlossomServersEvent.create(servers, signer, createdAt) + + @Test + fun `consume stores an incoming kind 10063 event in the addressable cache`() = + runTest { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + val servers = listOf("https://blossom.example.com", "https://cdn.example.org") + val event = signedServerList(servers, signer) + + cache.consume(event, relayUrl) + + val stored = cache.getOrCreateAddressableNote(event.address()).event as? BlossomServersEvent + assertNotNull(stored, "kind 10063 event must be stored in the addressable cache") + assertEquals(servers, stored.servers()) + } + + @Test + fun `an older event does not overwrite a newer one`() = + runTest { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + val newer = signedServerList(listOf("https://new.example.com"), signer, createdAt = 2_000) + val older = signedServerList(listOf("https://old.example.com"), signer, createdAt = 1_000) + + cache.consume(newer, relayUrl) + cache.consume(older, relayUrl) + + val stored = cache.getOrCreateAddressableNote(newer.address()).event as? BlossomServersEvent + assertEquals(listOf("https://new.example.com"), stored?.servers()) + } + + @Test + fun `BlossomServerListState surfaces the servers from the cached event`() = + runTest { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + val servers = listOf("https://blossom.example.com") + val event = signedServerList(servers, signer) + cache.consume(event, relayUrl) + + val state = + BlossomServerListState( + signer = signer, + cache = cache, + scope = backgroundScope, + ) + + assertEquals(servers, state.getBlossomServersList()?.servers()) + assertEquals(servers, state.flow.first { it.isNotEmpty() }) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopDmRelayListConsumeTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopDmRelayListConsumeTest.kt new file mode 100644 index 0000000000..8d0cbd22ce --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopDmRelayListConsumeTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.cache + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Verifies that [DesktopLocalCache] routes a NIP-17 DM relay list (kind 10050) + * into the cache so the [com.vitorpamplona.amethyst.commons.model.User] model + * reflects it. The desktop NIP-17 send path, the DM inbox resolver's local + * lookup, and tier-1 AUTH all read `User.dmInboxRelaysStrict()`; before the + * kind:10050 routing + `UserContext` wiring, `route` dropped kind 10050 and the + * User model was backed by a map nothing populated, so every recipient looked + * "unreachable via NIP-17" until an indexer fan-out ran. + */ +class DesktopDmRelayListConsumeTest { + private val relayUrl = NormalizedRelayUrl("wss://relay.test/") + + private val dmInbox = + listOf( + NormalizedRelayUrl("wss://inbox1.example/"), + NormalizedRelayUrl("wss://inbox2.example/"), + ) + + private fun signedDmRelayList( + signer: NostrSignerSync, + relays: List = dmInbox, + createdAt: Long = 1_700_000_000, + ): ChatMessageRelayListEvent = ChatMessageRelayListEvent.create(relays, signer, createdAt) + + @Test + fun `consume routes kind 10050 into the User model`() { + val cache = DesktopLocalCache() + val signer = NostrSignerSync(KeyPair()) + val event = signedDmRelayList(signer) + + cache.consume(event, relayUrl) + + val user = cache.getOrCreateUser(signer.pubKey) + assertEquals(dmInbox, user.dmInboxRelaysStrict()) + } + + @Test + fun `an unknown recipient has no strict DM relays`() { + val cache = DesktopLocalCache() + val stranger = NostrSignerSync(KeyPair()).pubKey + + assertNull(cache.getOrCreateUser(stranger).dmInboxRelaysStrict()) + } + + @Test + fun `a newer kind 10050 replaces an older one`() { + val cache = DesktopLocalCache() + val signer = NostrSignerSync(KeyPair()) + + cache.consume(signedDmRelayList(signer, dmInbox, createdAt = 1_700_000_000), relayUrl) + + val newerRelays = listOf(NormalizedRelayUrl("wss://moved.example/")) + cache.consume(signedDmRelayList(signer, newerRelays, createdAt = 1_700_000_100), relayUrl) + + assertEquals(newerRelays, cache.getOrCreateUser(signer.pubKey).dmInboxRelaysStrict()) + } + + @Test + fun `an older kind 10050 does not overwrite a newer one`() { + val cache = DesktopLocalCache() + val signer = NostrSignerSync(KeyPair()) + + cache.consume(signedDmRelayList(signer, dmInbox, createdAt = 1_700_000_100), relayUrl) + + val staleRelays = listOf(NormalizedRelayUrl("wss://stale.example/")) + cache.consume(signedDmRelayList(signer, staleRelays, createdAt = 1_700_000_000), relayUrl) + + assertEquals(dmInbox, cache.getOrCreateUser(signer.pubKey).dmInboxRelaysStrict()) + } +} diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 8419386004..b56145cbb7 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -122,18 +122,18 @@ "Vietnamese" ] }, - { - "user": "rajs19420616", - "languages": [ - "Hindi" - ] - }, { "user": "maxblake2015", "languages": [ "Polish" ] }, + { + "user": "rajs19420616", + "languages": [ + "Hindi" + ] + }, { "user": "summoner001", "languages": [ diff --git a/gradle.properties b/gradle.properties index 050eafd47c..0c0adeb89d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,4 +30,6 @@ kotlin.daemon.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=2g kotlin.mpp.applyDefaultHierarchyTemplate=false # This is needed for the Swift bridge to work. -kotlin.mpp.enableCInteropCommonization=true \ No newline at end of file +kotlin.mpp.enableCInteropCommonization=true +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/quartz/plans/2026-07-16-relay-limits.md b/quartz/plans/2026-07-16-relay-limits.md new file mode 100644 index 0000000000..b0426776b2 --- /dev/null +++ b/quartz/plans/2026-07-16-relay-limits.md @@ -0,0 +1,113 @@ +# Relay `LIMITS` message — status & roadmap + +Support for the relay-to-client `LIMITS` frame (from +[nostr-protocol/nips#1434](https://github.com/nostr-protocol/nips/pull/1434)), +where a relay advertises the current connection's rights and limits — on +connect and again whenever they change (e.g. after a NIP-42 AUTH flips +`can_write`). Live-verified against `wss://pipe.imwald.eu/` (which sends `AUTH` +then `LIMITS`). + +Note: `LIMITS` is **not** NIP-22 (that's Comment / kind 1111); it's the #1434 +proposal and is label-based on the wire, so the code keys off the `"LIMITS"` +label rather than a NIP number. + +## Done (branch `claude/limits-message-support-g5plrd`, PR #3597) + +- **Parse** — `LimitsMessage` model (`nip01Core/relay/commands/toClient/`) with + every #1434 field plus the two non-spec extensions this relay sends + (`auth_for_read` / `auth_for_write`). Wired into both codecs: + Jackson (`LimitsDeserializer` + `MessageSerializer`, jvmAndroid) and + kotlinx (`LimitsKSerializer` + `MessageKSerializer`, iOS/native). Unknown + fields are ignored, so a future field can't re-trigger the original + "Message LIMITS is not supported" crash. +- **Cache/expose** — `RelayLimitsTracker` (`nip01Core/relay/client/limits/`), + a passive `RelayConnectionListener` accessory (modeled on + `RelayAuthenticator`) that caches the latest `LimitsMessage` per + `NormalizedRelayUrl` and publishes a Compose-stable + `StateFlow>`. Connection-scoped (dropped on + disconnect). Wired in `AppModules` as `Amethyst.instance.relayLimits`. +- **Cleanup** — removed the unused experimental prototype + (`experimental/limits/Limits.kt` + `LimitProcessor.kt`); renamed the client + accessory to `…Tracker` to avoid colliding with the server-side + `relay/server/policies/RelayLimits`. + +## Audit fixes to fold in (small, do first) + +From the 2026-07-16 review of the branch: + +1. **kotlinx parse should be as lenient as Jackson.** `MessageKSerializer` is + the *iOS* incoming path; today `LimitsKSerializer.deserializeFromElement` + throws on a mistyped field (`(tag as JsonArray)`, `.jsonPrimitive.int`) or a + payload-less `["LIMITS"]` (`array[1].jsonObject`), where Jackson coerces. + Same frame → parsed on Android, dropped on iOS. Use `intOrNull` / + `booleanOrNull` / `longOrNull`, guard the array cast, and default a missing + payload object to an empty `LimitsMessage`. +2. **Drop the stale "NIP-22" labels** in `LimitsKSerializer` KDoc and the + `// NIP-22 wire format` comments in `MessageKSerializer` / `MessageSerializer` + (already removed from `LimitsMessage.kt`). +3. **Make `LimitsMessage` a `data class`** so `StateFlow.distinctUntilChanged` + suppresses no-op emissions when a relay re-advertises identical limits, and + tests get value equality. +4. Fold the explicit-`null`→`false`/`0` coercion nuance into (1) via the + `…OrNull` accessors on both sides. + +## Roadmap + +### 1. Client-side apply/enforcement (highest user-facing value) + +#1434 says clients MUST apply limits when sending. Provide **pure, testable +helpers on `LimitsMessage`** (mirror the server's `LimitsPolicy` logic; the +deleted `LimitProcessor` in git history is a starting sketch): + +- `clamp(filter)` / `clamp(filters)` → cap each filter `limit` to `max_limit`. +- `rejectionForPublish(event): String?` → `can_write`, `accepted`/`blocked + _event_kinds`, `max_content_length`, `max_event_tags`, `min_pow_difficulty`, + `created_at_msecs_ago`/`ahead` window, `required_tags`. +- `canRead()` / `canWrite()` convenience. + +Keep the helpers in quartz (pure, no side effects). Wire them into the send +path at the app layer (subscription/filter-assembly + publish), reading from +`RelayLimitsTracker`. Decide the UX for a rejected publish (surface vs +silently drop) with the maintainer — this is the one opinionated call. + +### 2. Server-side emit (`geode`) — the symmetric half + +The relay side already **enforces** (`LimitsPolicy` + `RelaySession`) and +**advertises via NIP-11** (`RelayLimits.toNip11Limitation()`), but never sends +the dynamic `LIMITS` frame. Add: + +- `RelayLimits.toLimitsMessage(canRead, canWrite, authForRead, authForWrite)` + next to `toNip11Limitation()`, so one source of truth drives NIP-11 *and* the + frame. +- `RelaySession` / `EventSourceServer` sends `LIMITS` on connect and **re-sends + after AUTH** when effective rights change. +- Then geode ↔ our own client becomes interop-testable (relayBench / the amy + serve path). + +### 3. NIP-11 ↔ LIMITS bridge + +The two overlap ~80% (`RelayInformationLimitation` vs `LimitsMessage`). Add +`LimitsMessage.toNip11Limitation()` / `RelayInformationLimitation +.toLimitsMessage()`, and let `RelayLimitsTracker` **seed** from the NIP-11 doc +before the socket connects, then override with the live frame — one "limits of +relay X" model regardless of source. + +### 4. Tooling, tests, docs + +- `amy relay info` already prints the NIP-11 doc; add the live `LIMITS` frame + (the interop tool the repo already leans on). +- Catalog `RelayLimitsTracker` in + `nip01Core/relay/client/accessories/README.md`. +- Test the **AUTH → re-LIMITS** transition (`auth_for_write` flipping after a + successful AUTH) — the one dynamic behavior not yet exercised live. + +### 5. Spec follow-up + +`auth_for_read` / `auth_for_write` are not in #1434. Worth a note on the PR; +we tolerate them as extras either way (they pair with the `AUTH` this relay +also sends). + +## Suggested sequencing + +audit fixes → (1) client apply helpers → (2) server emit → (3) NIP-11 bridge → +(4) tooling. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt index 88fc9e9702..6feab287b2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.quartz.concord.cord05Invites import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.Event @@ -31,6 +33,34 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.utils.RandomInstance +/** + * What the events fetched at an invite's addressable coordinate `(33301, + * link_signer, d="")` actually resolve to under CORD-05 §2. The coordinate is + * replaceable, so its live state is the **newest** event: a revocation tombstone + * (`vsk=9`) supersedes and buries the bundle even if an older openable copy still + * lingers on some relay, which is exactly the "fetcher finds the grave instead of + * keys" behaviour the spec mandates. + */ +sealed interface InviteBundleStatus { + /** A live `vsk=6` bundle that opened with the link token. */ + data class Live( + val invite: CommunityInvite, + ) : InviteBundleStatus + + /** The newest event at the coordinate is a `vsk=9` revocation tombstone — the link was retired. */ + data object Revoked : InviteBundleStatus + + /** + * Something is at the coordinate, but it isn't a `vsk=6` bundle this client can open — + * a wrong/expired token, or a sub-kind (e.g. a mis-posted registry `vsk=8`) or format + * newer than we support. + */ + data object Unreadable : InviteBundleStatus + + /** Nothing was found at the coordinate on any queried relay (unreachable, expired, or not yet propagated). */ + data object Absent : InviteBundleStatus +} + /** A freshly minted public invite link: the shareable URL, the link keys, and the bundle to publish. */ class MintedInviteLink( val url: String, @@ -82,6 +112,25 @@ object ConcordInviteBundle { } } + /** + * Resolves the events fetched at an invite's addressable coordinate into a single + * [InviteBundleStatus] under CORD-05 §2 replaceable semantics. The newest event + * wins: a `vsk=9` revocation tombstone marks the link [InviteBundleStatus.Revoked] + * even when an older, still-openable bundle is also present (so a stale relay copy + * can't resurrect a retired link). Otherwise the first `vsk=6` bundle that opens + + * validates with [token] is [InviteBundleStatus.Live]; anything else present is + * [InviteBundleStatus.Unreadable], and an empty set is [InviteBundleStatus.Absent]. + */ + fun classify( + wraps: List, + token: ByteArray, + ): InviteBundleStatus { + val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent + if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked + val invite = wraps.firstNotNullOfOrNull { parse(it, token)?.takeIf { i -> validate(i) } } + return if (invite != null) InviteBundleStatus.Live(invite) else InviteBundleStatus.Unreadable + } + /** * Validates that an [invite]'s owner + salt actually reproduce its * community_id (CORD-02 self-certification), so a bundle can't smuggle a false diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt deleted file mode 100644 index 3bd5129456..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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.experimental.limits - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip13Pow.pow -import com.vitorpamplona.quartz.utils.TimeUtils - -class LimitProcessor { - fun wrapFilterToLimits( - filter: Filter, - sendingStr: String, - limits: Limits, - ): Filter? { - var newFilter: Filter? = filter - - if (limits.canRead != null && !limits.canRead) { - newFilter = null - } - - if (limits.maxLimit != null && filter.limit != null && filter.limit > limits.maxLimit) { - newFilter = filter.copy(limit = limits.maxLimit) - } - - if (!limits.acceptedEventKinds.isNullOrEmpty() && !filter.kinds.isNullOrEmpty()) { - val intersect = filter.kinds.filter { it in limits.acceptedEventKinds } - if (intersect.isNotEmpty()) { - newFilter = filter.copy(kinds = intersect) - } else { - newFilter = null - } - } - - if (!limits.blockedEventKinds.isNullOrEmpty() && !filter.kinds.isNullOrEmpty()) { - val intersect = filter.kinds.filter { it !in limits.blockedEventKinds } - if (intersect.isNotEmpty()) { - newFilter = filter.copy(kinds = intersect) - } else { - newFilter = null - } - } - - if (limits.maxMessageLength != null && sendingStr.length > limits.maxMessageLength) { - // TODO: figure out how to dynamically reduce filter size - newFilter = null - } - - return newFilter - } - - fun canSendEvent( - ev: Event, - sendingStr: String, - limits: Limits, - ): Boolean { - if (limits.canWrite != null && !limits.canWrite) return false - - if (!limits.acceptedEventKinds.isNullOrEmpty() && ev.kind !in limits.acceptedEventKinds) return false - if (!limits.blockedEventKinds.isNullOrEmpty() && ev.kind in limits.blockedEventKinds) return false - - if (limits.minPoW != null && ev.pow() < limits.minPoW) return false - if (limits.maxEventTags != null && ev.tags.size > limits.maxEventTags) return false - if (limits.maxContentLength != null && ev.content.length > limits.maxContentLength) return false - - if (limits.createdAtMillisecsAgo != null && ev.createdAt < TimeUtils.now() - limits.createdAtMillisecsAgo) return false - if (limits.createdAtMillisecsAhead != null && ev.createdAt > TimeUtils.now() + limits.createdAtMillisecsAhead) return false - - if (limits.requiredTags != null && !matchAll(ev, limits.requiredTags)) return false - - if (limits.maxMessageLength != null && sendingStr.length > limits.maxMessageLength) return false - - return true - } - - private fun matchAll( - ev: Event, - requiredTags: Array>, - ): Boolean = - requiredTags.all { requiredTag -> - if (requiredTag.isNotEmpty()) { - if (requiredTag.getOrNull(1) == null) { - ev.tags.any { eventTag -> - eventTag.getOrNull(0) == requiredTag[0] - } - } else { - ev.tags.any { eventTag -> - eventTag.getOrNull(0) == requiredTag[0] && eventTag.getOrNull(1) == requiredTag[1] - } - } - } else { - true - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/Limits.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/Limits.kt deleted file mode 100644 index 2526e377f9..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/Limits.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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.experimental.limits - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -class Limits( - @SerialName("can_write") val canWrite: Boolean?, - @SerialName("can_read") val canRead: Boolean?, - @SerialName("accepted_event_kinds") val acceptedEventKinds: Set?, - @SerialName("blocked_event_kinds") val blockedEventKinds: Set?, - @SerialName("min_pow_difficulty") val minPoW: Int?, - @SerialName("max_message_length") val maxMessageLength: Int?, - @SerialName("max_subscriptions") val maxSubscriptions: Int?, - @SerialName("max_filters") val maxFilters: Int?, - @SerialName("max_limit") val maxLimit: Int?, - @SerialName("max_event_tags") val maxEventTags: Int?, - @SerialName("max_content_length") val maxContentLength: Int?, - @SerialName("created_at_msecs_ago") val createdAtMillisecsAgo: Long?, - @SerialName("created_at_msecs_ahead") val createdAtMillisecsAhead: Long?, - @SerialName("filter_rate_limit") val filterRateLimit: Long?, - @SerialName("publishing_rate_limit") val publishingRateLimit: Long?, - @SerialName("required_tags") val requiredTags: Array>?, -) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt deleted file mode 100644 index f30941c49c..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt +++ /dev/null @@ -1,150 +0,0 @@ -/* - * 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.experimental.profileGallery - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag -import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent -import com.vitorpamplona.quartz.utils.TimeUtils - -@Deprecated("Replaced by NIP-68") -@Immutable -class GalleryListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Suppress("DEPRECATION") - companion object { - const val KIND = 10011 - const val GALLERY_TAG_NAME = "url" - - suspend fun addEvent( - earlierVersion: GalleryListEvent?, - eventId: HexKey, - url: String, - relay: String?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ) = addTag(earlierVersion, GALLERY_TAG_NAME, eventId, url, relay, signer, createdAt) - - suspend fun addTag( - earlierVersion: GalleryListEvent?, - tagName: String, - eventId: HexKey, - url: String, - relay: String?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): GalleryListEvent { - val tags = arrayOf(tagName, url, eventId) - if (relay != null) { - tags + relay - } - - return add( - earlierVersion, - arrayOf(tags), - signer, - createdAt, - ) - } - - suspend fun add( - earlierVersion: GalleryListEvent?, - listNewTags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): GalleryListEvent = - create( - content = earlierVersion?.content ?: "", - tags = listNewTags.plus(earlierVersion?.tags ?: arrayOf()), - signer = signer, - createdAt = createdAt, - ) - - suspend fun removeEvent( - earlierVersion: GalleryListEvent, - eventId: HexKey, - url: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ) = removeTag(earlierVersion, GALLERY_TAG_NAME, eventId, url, signer, createdAt) - - suspend fun removeReplaceable( - earlierVersion: GalleryListEvent, - aTag: ATag, - url: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ) = removeTag(earlierVersion, GALLERY_TAG_NAME, aTag.toTag(), url, signer, createdAt) - - private suspend fun removeTag( - earlierVersion: GalleryListEvent, - tagName: String, - eventId: HexKey, - url: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): GalleryListEvent = - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == url && it[2] == eventId) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - ) - - suspend fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): GalleryListEvent = signer.sign(createdAt, KIND, tags, content) - } - - @Immutable - data class GalleryUrl( - val url: String, - val id: String, - val relay: String?, - ) { - fun encode(): String = ":$url:$id:$relay" - - companion object { - fun decode(encodedGallerySetup: String): GalleryUrl? { - val galleryParts = encodedGallerySetup.split(":", limit = 3) - return if (galleryParts.size > 3) { - GalleryUrl(galleryParts[1], galleryParts[2], galleryParts[3]) - } else { - null - } - } - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt new file mode 100644 index 0000000000..85d6c5c9a4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt @@ -0,0 +1,109 @@ +/* + * 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.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonArray +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray + +/** kotlinx-serialization codec for the `LIMITS` object payload. */ +object LimitsKSerializer { + // Tolerant readers so a malformed/mistyped field degrades to null instead of + // throwing: this is the iOS/native incoming-parse path, and it must match the + // leniency of the Jackson path (LimitsDeserializer) used on jvmAndroid. + private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull + + private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull + + private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull + + private fun JsonObject.intList(key: String): List? = (this[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.intOrNull } + + private fun JsonObject.tagList(key: String): List>? = + (this[key] as? JsonArray)?.map { tag -> + (tag as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList() + } + + fun serializeToElement(value: LimitsMessage): JsonObject = + buildJsonObject { + // Only emit the fields the relay actually set; absent limits stay absent. + value.canWrite?.let { put("can_write", it) } + value.canRead?.let { put("can_read", it) } + value.authForRead?.let { put("auth_for_read", it) } + value.authForWrite?.let { put("auth_for_write", it) } + value.acceptedEventKinds?.let { kinds -> + putJsonArray("accepted_event_kinds") { kinds.forEach { add(it) } } + } + value.blockedEventKinds?.let { kinds -> + putJsonArray("blocked_event_kinds") { kinds.forEach { add(it) } } + } + value.minPowDifficulty?.let { put("min_pow_difficulty", it) } + value.maxMessageLength?.let { put("max_message_length", it) } + value.maxSubscriptions?.let { put("max_subscriptions", it) } + value.maxFilters?.let { put("max_filters", it) } + value.maxLimit?.let { put("max_limit", it) } + value.maxEventTags?.let { put("max_event_tags", it) } + value.maxContentLength?.let { put("max_content_length", it) } + value.createdAtMsecsAgo?.let { put("created_at_msecs_ago", it) } + value.createdAtMsecsAhead?.let { put("created_at_msecs_ahead", it) } + value.filterRateLimit?.let { put("filter_rate_limit", it) } + value.publishingRateLimit?.let { put("publishing_rate_limit", it) } + value.requiredTags?.let { tags -> + putJsonArray("required_tags") { + tags.forEach { tag -> + addJsonArray { tag.forEach { part -> add(part) } } + } + } + } + } + + fun deserializeFromElement(jsonObject: JsonObject): LimitsMessage = + LimitsMessage( + canWrite = jsonObject.bool("can_write"), + canRead = jsonObject.bool("can_read"), + authForRead = jsonObject.bool("auth_for_read"), + authForWrite = jsonObject.bool("auth_for_write"), + acceptedEventKinds = jsonObject.intList("accepted_event_kinds"), + blockedEventKinds = jsonObject.intList("blocked_event_kinds"), + minPowDifficulty = jsonObject.int("min_pow_difficulty"), + maxMessageLength = jsonObject.int("max_message_length"), + maxSubscriptions = jsonObject.int("max_subscriptions"), + maxFilters = jsonObject.int("max_filters"), + maxLimit = jsonObject.int("max_limit"), + maxEventTags = jsonObject.int("max_event_tags"), + maxContentLength = jsonObject.int("max_content_length"), + createdAtMsecsAgo = jsonObject.long("created_at_msecs_ago"), + createdAtMsecsAhead = jsonObject.long("created_at_msecs_ahead"), + filterRateLimit = jsonObject.long("filter_rate_limit"), + publishingRateLimit = jsonObject.long("publishing_rate_limit"), + requiredTags = jsonObject.tagList("required_tags"), + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 800420e49c..adba985212 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage @@ -38,6 +39,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonArray @@ -97,6 +99,11 @@ object MessageKSerializer : KSerializer { add(JsonPrimitive(value.subId)) } + is LimitsMessage -> { + // LIMITS wire format: ["LIMITS", { }] + add(LimitsKSerializer.serializeToElement(value)) + } + is NegMsgMessage -> { add(JsonPrimitive(value.subId)) add(JsonPrimitive(value.message)) @@ -160,6 +167,12 @@ object MessageKSerializer : KSerializer { CountMessage(queryId, result) } + LimitsMessage.LABEL -> { + // Tolerate a payload-less or malformed ["LIMITS"] frame instead of throwing. + val payload = array.getOrNull(1) as? JsonObject ?: JsonObject(emptyMap()) + LimitsKSerializer.deserializeFromElement(payload) + } + NegMsgMessage.LABEL -> { NegMsgMessage( subId = array[1].jsonPrimitive.content, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index ba3c1e5a34..f1b95de2f2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage @@ -64,6 +65,7 @@ class RelayLogger( is AuthMessage -> if (debugReceiving) Log.d(logTag) { "Auth: ${msg.challenge}" } is NotifyMessage -> if (debugReceiving) Log.d(logTag) { "Notify: ${msg.message}" } is CountMessage -> if (debugReceiving) Log.d(logTag) { "Count: ${msg.result.count} approx: ${msg.result.approximate} hll: ${msg.result.hll != null}" } + is LimitsMessage -> if (debugReceiving) Log.d(logTag) { "Limits: canRead=${msg.canRead} canWrite=${msg.canWrite} maxLimit=${msg.maxLimit} maxSubscriptions=${msg.maxSubscriptions}" } is ClosedMessage -> Log.w(logTag) { "Closed: ${msg.subId} ${msg.message}" } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt new file mode 100644 index 0000000000..a8ffa37d63 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt @@ -0,0 +1,104 @@ +/* + * 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.limits + +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.toClient.LimitsMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Caches the latest `LIMITS` (relay rights + limits) advertised by each relay. + * + * A relay sends `LIMITS` on connect and again whenever the connection's rights + * change (e.g. after a successful NIP-42 AUTH flips `can_write` on). Like + * [com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator], + * this is an accessory that registers a [RelayConnectionListener], keeps + * per-relay state, and publishes it as a Compose-stable [StateFlow] — but it is + * purely passive: it never replies to the relay. + * + * The cache is connection-scoped: a relay's entry is dropped on disconnect, so a + * stale limit from a previous session never leaks into a new one. A fresh + * connection re-advertises `LIMITS` on connect. + * + * Named `…Tracker` to avoid colliding with the relay-*server* side's + * [com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits], which + * is the operator-configured source of truth a relay enforces and advertises. + */ +class RelayLimitsTracker( + val client: INostrClient, +) { + // onIncomingMessage / onDisconnected fire on the per-relay socket dispatcher + // thread, so this is mutated concurrently. MutableStateFlow.update is an + // atomic compare-and-set loop over an immutable PersistentMap, so concurrent + // writers from different relays never corrupt the map or lose an update. + private val _limitsFlow = MutableStateFlow>(persistentMapOf()) + + /** + * Per-relay `LIMITS` as an immutable, Compose-stable snapshot map. The map + * identity changes on every mutation, so downstream + * [kotlinx.coroutines.flow.distinctUntilChanged] and Compose `@Immutable` + * skipping both work correctly. + */ + val limitsFlow: StateFlow> = _limitsFlow.asStateFlow() + + /** The most recent `LIMITS` the relay advertised, or null if none seen on the current connection. */ + fun get(url: NormalizedRelayUrl): LimitsMessage? = _limitsFlow.value[url] + + fun snapshot(): Map = _limitsFlow.value + + private val clientListener = + object : RelayConnectionListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is LimitsMessage) { + _limitsFlow.update { it.putting(relay.url, msg) } + } + } + + override fun onDisconnected(relay: IRelayClient) { + _limitsFlow.update { it.removing(relay.url) } + } + } + + init { + Log.d("RelayLimitsTracker", "Init, Subscribe") + client.addConnectionListener(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("RelayLimitsTracker", "Destroy, Unsubscribe") + client.removeConnectionListener(clientListener) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt new file mode 100644 index 0000000000..1ecd2f20f7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt @@ -0,0 +1,81 @@ +/* + * 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.commands.toClient + +import androidx.compose.runtime.Immutable + +/** + * `LIMITS` message: the relay advertises the current rights and limits + * for this connection. Sent upon connection and again at any point during the + * connection to reflect the client's changing rights (e.g. after a NIP-42 AUTH). + * + * Wire format: `["LIMITS", { }]`. + * + * Every field is optional: a relay only sends the limits it enforces, and a + * missing field means "unspecified / no advertised limit" — clients should keep + * any previously cached value and not assume a default. Clients cache this + * payload on the relay connection and apply it when sending `EVENT`s and `REQ`s. + */ +@Immutable +data class LimitsMessage( + /** Whether clients may publish events to this relay. */ + val canWrite: Boolean? = null, + /** Whether clients may send `REQ` commands to this relay. */ + val canRead: Boolean? = null, + /** Whether the client must authenticate (NIP-42) before reading. */ + val authForRead: Boolean? = null, + /** Whether the client must authenticate (NIP-42) before writing. */ + val authForWrite: Boolean? = null, + /** Allowlist of event kinds the relay accepts for publishing. */ + val acceptedEventKinds: List? = null, + /** Denylist of event kinds the relay rejects for publishing. */ + val blockedEventKinds: List? = null, + /** Minimum proof-of-work (NIP-13) difficulty in bits required to publish. */ + val minPowDifficulty: Int? = null, + /** Maximum byte length for published events and `REQ` filters. */ + val maxMessageLength: Int? = null, + /** Maximum number of concurrent subscriptions allowed. */ + val maxSubscriptions: Int? = null, + /** Maximum number of filters allowed per `REQ` command. */ + val maxFilters: Int? = null, + /** Maximum value the relay honors for a filter's `limit` (use pagination for more). */ + val maxLimit: Int? = null, + /** Maximum number of tags allowed in a published event. */ + val maxEventTags: Int? = null, + /** Maximum length of the `content` field of a published event. */ + val maxContentLength: Int? = null, + /** Minimum event `created_at` recency, in milliseconds before now. */ + val createdAtMsecsAgo: Long? = null, + /** Maximum event `created_at` in the future, in milliseconds ahead of now. */ + val createdAtMsecsAhead: Long? = null, + /** Minimum debounce interval, in milliseconds, between filter changes. */ + val filterRateLimit: Long? = null, + /** Minimum debounce interval, in milliseconds, between publishes. */ + val publishingRateLimit: Long? = null, + /** Tags that must be present on published events, as `[key, optional value]` pairs. */ + val requiredTags: List>? = null, +) : Message { + override fun label() = LABEL + + companion object { + const val LABEL = "LIMITS" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt index 552e5f6afc..e2b9465bb2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt @@ -53,6 +53,7 @@ data class Nip11RelayInformation( val fees: RelayInformationFees? = null, val nip50: List? = null, val supported_grasps: List? = null, + val nip29: Nip29Support? = null, ) { /** * Serializes this document to JSON for serving at the relay's root over the @@ -67,6 +68,17 @@ data class Nip11RelayInformation( fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson(json) } + /** + * NIP-29 relay capability advertisement. A relay that supports the subgroup + * hierarchy sets `nip29: { "subgroups": true }` in its NIP-11 document so + * clients know they can offer parent/child grouping for this relay's groups. + */ + @Stable + @Serializable + data class Nip29Support( + val subgroups: Boolean? = null, + ) + @Stable @Serializable data class RelayInformationFee( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt index 8a5fbcd460..54d77d8674 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip11RelayInfo import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.Nip29Support import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFee import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFees import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationLimitation @@ -87,6 +88,7 @@ class Nip11RelayInformationBuilder { private var limitation: RelayInformationLimitation? = null private var fees: RelayInformationFees? = null + private var nip29: Nip29Support? = null /** Advertise supported NIP numbers, e.g. `supports(1, 11, 42, 50)`. Repeatable. */ fun supports(vararg nips: Int) = apply { nips.forEach { supportedNips.add(it.toString()) } } @@ -112,6 +114,9 @@ class Nip11RelayInformationBuilder { /** GRASP git-server capabilities the relay implements (`supported_grasps`). Repeatable. */ fun grasps(vararg values: String) = apply { supportedGrasps.addAll(values) } + /** Advertise NIP-29 subgroup support (`nip29: { "subgroups": true }`). */ + fun subgroups(supported: Boolean = true) = apply { nip29 = Nip29Support(subgroups = supported) } + /** Declare the relay's `limitation` object via a nested DSL. */ fun limitation(initializer: LimitationBuilder.() -> Unit) = apply { @@ -165,6 +170,7 @@ class Nip11RelayInformationBuilder { fees = fees, nip50 = nip50Subfeatures.ifEmpty { null }?.toList(), supported_grasps = supportedGrasps.ifEmpty { null }?.toList(), + nip29 = nip29, ) @Nip11DslMarker diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt new file mode 100644 index 0000000000..ac59490d4a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt @@ -0,0 +1,57 @@ +/* + * 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.nip29RelayGroups + +/** + * Reads the optional invite code appended to a NIP-29 group identifier. + * + * The spec shares a group as its `kind:39000` `naddr1…` and lets an invite code be + * appended as a query suffix: + * ``` + * naddr1…?invite= + * ``` + * Because the bech32 charset has no `?`, everything before the `?` is still a valid + * naddr on its own — so a NIP-19 parser hands back the `?invite=` remainder as + * the "additional characters" after the entity. This extracts the `invite` value from + * that remainder. Clients then send it in the `code` tag of the `kind:9021` join + * request. A client that doesn't understand the suffix can simply ignore it. + */ +object GroupNAddrInvite { + private const val PARAM = "invite" + + /** + * Extracts the invite code from the [suffix] that trails a group `naddr` (e.g. + * `?invite=abc123` or `?foo=bar&invite=abc123`), or null when there is none. + */ + fun parse(suffix: String?): String? { + if (suffix.isNullOrEmpty()) return null + // Normally the code trails the naddr as `?invite=`. Fall back to a bare + // `invite=` remainder too, in case an upstream parser strips the `?`. + val query = if ('?' in suffix) suffix.substringAfter('?') else suffix + if (query.isEmpty()) return null + + return query + .split('&') + .firstOrNull { it.startsWith("$PARAM=") } + ?.removePrefix("$PARAM=") + ?.takeIf { it.isNotEmpty() } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt new file mode 100644 index 0000000000..af3e53593e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt @@ -0,0 +1,102 @@ +/* + * 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.nip29RelayGroups + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent + +/** A group and its (recursively assembled) subgroups. */ +@Immutable +data class GroupTreeNode( + val metadata: GroupMetadataEvent, + val children: List, +) + +/** + * Assembles the NIP-29 subgroup hierarchy from a flat set of `kind:39000` + * metadata events (all from the same relay — the tree is relay-scoped). + * + * Structure follows each group's own `parent` tag, which is the single source of + * truth for who a group's parent is (a group carries at most one `parent`, so a + * group can never end up under two parents). A parent's advertised `child` tag + * order is used only to order siblings; children a parent hasn't listed yet are + * appended after the listed ones in a stable order. + * + * Robustness rules, matching the spec's relay behaviour: + * - A group whose declared parent is not present in the set is treated as a root + * (the spec has relays reject such edits, but a client aggregating a partial + * view must still show the group rather than drop it). + * - Cycles cannot occur on a compliant relay (it rejects any `kind:9002` that + * would create one), but if malformed data produces one it is broken and the + * involved groups surface as roots rather than looping forever. + * - When multiple `kind:39000` events share a `d` id the newest wins. + */ +object SubgroupTree { + fun build(events: Collection): List { + val byId = LinkedHashMap() + events.forEach { event -> + val id = event.groupId().ifEmpty { return@forEach } + val existing = byId[id] + if (existing == null || event.createdAt >= existing.createdAt) byId[id] = event + } + + val childrenOf = HashMap>() + byId.values.forEach { event -> + val parent = event.parent() + if (parent != null && parent in byId) { + childrenOf.getOrPut(parent) { mutableListOf() }.add(event) + } + } + + val placed = HashSet() + + fun node(event: GroupMetadataEvent): GroupTreeNode { + val id = event.groupId() + placed.add(id) + + // Order siblings by the parent's advertised `child` tag order; anything + // the parent hasn't listed keeps its natural order after the listed ones. + val order = event.children().withIndex().associate { (index, childId) -> childId to index } + val orderedChildren = + childrenOf[id] + .orEmpty() + .filter { it.groupId() !in placed } // cycle guard + .sortedBy { order[it.groupId()] ?: Int.MAX_VALUE } + + return GroupTreeNode(event, orderedChildren.map { node(it) }) + } + + val roots = + byId.values.filter { + val parent = it.parent() + parent == null || parent !in byId + } + + val tree = roots.map { node(it) }.toMutableList() + + // Safety net: any group left unplaced was part of a cycle — surface it as a root. + byId.values.forEach { event -> + if (event.groupId() !in placed) tree.add(node(event)) + } + + return tree + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index 618c41fab1..b708ca5b24 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -83,6 +85,22 @@ class GroupMetadataEvent( /** Group supports LiveKit-powered live audio/video. Presence of the `livekit` flag. */ fun hasLivekit() = tags.hasTagName("livekit") + /** + * Subgroups: the id of this group's parent, or null when this is a root group. + * At most one `parent` tag is expected (NIP-29 §Subgroups). + */ + fun parent(): String? = tags.firstNotNullOfOrNull(ParentTag::parse) + + /** + * Subgroups: the ordered ids of this group's direct children. The position of + * each `child` tag in the array is the intended display order. Empty when the + * group has no children (or the relay doesn't advertise them). + */ + fun children(): List = tags.mapNotNull(ChildTag::parse) + + /** A group with no `parent` tag is a root group in the subgroup tree. */ + fun isRoot(): Boolean = parent() == null + /** * The kinds this group accepts, when constrained, e.g. `["supported_kinds", "9", "11"]`. * `null` (tag absent) means all kinds are accepted. @@ -127,6 +145,8 @@ class GroupMetadataEvent( supportedKinds: List? = null, hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = null, + children: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -141,6 +161,8 @@ class GroupMetadataEvent( addAll(HashtagTag.assemble(hashtags)) // Mip-map each geohash into every prefix so a coarser followed geohash still matches. geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } + parent?.let { add(ParentTag.assemble(it)) } + addAll(ChildTag.assemble(children)) initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt new file mode 100644 index 0000000000..a15aa5f589 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt @@ -0,0 +1,69 @@ +/* + * 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.nip29RelayGroups.metadata + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-29 relay-signed list of a group's pinned messages (kind 39005). The relay + * regenerates it from the accepted [com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent] + * (kind 9010) moderation actions, so this is the read side clients render — the + * source of truth for which messages are pinned and in what display order. + * + * Addressed by the group id (`d` tag). The pinned event ids are carried as `e` + * tags in display order. + */ +@Immutable +class GroupPinnedEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = dTag() + + /** Pinned message ids, in the relay's display order. */ + fun pinnedEventIds(): List = tags.mapValueTagged("e") { it } + + companion object { + const val KIND = 39005 + + fun build( + groupId: String, + pinnedEventIds: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(groupId) + pinnedEventIds.forEach { add(arrayOf("e", it)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt index 00695e733e..df9fa03c92 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt @@ -54,6 +54,16 @@ class EditMetadataEvent( fun geohashes() = tags.geohashes() + /** Subgroups: the requested parent group id, or null to (re-)root this group. */ + fun parent() = tags.parentGroupId() + + /** + * Subgroups: the ordered child ids carried on this edit. Per NIP-29 a metadata + * edit of a parent group MUST re-list all of its children, so the relay rejects + * a `kind:9002` that drops any of them. + */ + fun children() = tags.childGroupIds() + fun previousEvents() = tags.previousEvents() override fun indexableContent() = listOfNotNull(name(), about()).joinToString("\n") @@ -69,6 +79,8 @@ class EditMetadataEvent( status: Set = emptySet(), hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = null, + children: List = emptyList(), previousEvents: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, @@ -81,6 +93,8 @@ class EditMetadataEvent( addAll(HashtagTag.assemble(hashtags)) // Mip-map each geohash into every prefix so a coarser followed geohash still matches. geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } + parent?.let { parentGroup(it) } + childGroups(children) previous(previousEvents) initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt index 00e5fe2f68..89e095edc2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt @@ -23,14 +23,22 @@ package com.vitorpamplona.quartz.nip29RelayGroups.moderation import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag fun TagArrayBuilder.groupId(groupId: String) = addUnique(GroupIdTag.assemble(groupId)) fun TagArrayBuilder.previous(eventIdPrefixes: List) = addAll(PreviousTag.assemble(eventIdPrefixes)) +/** Sets the subgroup `parent` tag (the parent group's id). At most one per event. */ +fun TagArrayBuilder.parentGroup(parentGroupId: String) = addUnique(ParentTag.assemble(parentGroupId)) + +/** Appends the ordered `child` subgroup tags. */ +fun TagArrayBuilder.childGroups(childGroupIds: List) = addAll(ChildTag.assemble(childGroupIds)) + fun TagArrayBuilder.userPubKey(pubKey: HexKey) = add(arrayOf("p", pubKey)) fun TagArrayBuilder.userPubKeyWithRoles( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt index 318fed79ac..e3cc924fc2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt @@ -25,16 +25,26 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag fun TagArray.groupId() = firstTagValue(GroupIdTag.TAG_NAME) fun TagArray.previousEvents() = mapNotNull(PreviousTag::parse) +/** The `parent` group id (subgroups), or null when this is a root group. At most one is expected. */ +fun TagArray.parentGroupId() = firstNotNullOfOrNull(ParentTag::parse) + +/** The ordered list of direct `child` subgroup ids advertised on a parent's metadata. */ +fun TagArray.childGroupIds(): List = mapNotNull(ChildTag::parse) + fun TagArray.userPubKeys(): List = mapNotNull(PTag::parseKey) fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it } +fun TagArray.pinnedEventIds(): List = mapValueTagged("e") { it } + fun TagArray.inviteCode() = firstNotNullOfOrNull(CodeTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt new file mode 100644 index 0000000000..9a53d7e7cc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt @@ -0,0 +1,64 @@ +/* + * 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.nip29RelayGroups.moderation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-29 `update-pin-list` moderation event (kind 9010). Carries the group `h` + * tag plus the FULL list of pinned message ids as `e` tags — pinning, unpinning, + * reordering and clearing pins are all done by submitting a new complete list. + * The relay checks the sender's role, applies it, and republishes the group's + * kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent]. + */ +@Immutable +class UpdatePinListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = tags.groupId() + + fun pinnedEventIds() = tags.pinnedEventIds() + + companion object { + const val KIND = 9010 + + fun build( + groupId: String, + pinnedEventIds: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + groupId(groupId) + pinnedEventIds.forEach { add(arrayOf("e", it)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt new file mode 100644 index 0000000000..0f934a41e3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt @@ -0,0 +1,48 @@ +/* + * 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.nip29RelayGroups.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-29 subgroups `child` tag. A parent group's `kind:39000` metadata carries one + * `child` tag per direct subgroup, pointing at the child's `d` identifier. The + * order of the `child` tags in the array is the display order of the children; the + * relay appends new children as they are created and an admin can reorder them via + * a `kind:9002` carrying the full desired `child` list. + */ +class ChildTag { + companion object { + const val TAG_NAME = "child" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(childGroupId: String) = arrayOf(TAG_NAME, childGroupId) + + fun assemble(childGroupIds: List) = childGroupIds.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt new file mode 100644 index 0000000000..dd724de792 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt @@ -0,0 +1,46 @@ +/* + * 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.nip29RelayGroups.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-29 subgroups `parent` tag. Points at the parent group's `d` identifier and + * appears on the subgroup's own `kind:39000` metadata (and on the `kind:9002` + * edit-metadata request that sets it). A group with no `parent` tag is a root. + * + * The spec allows at most one `parent` tag per group. + */ +class ParentTag { + companion object { + const val TAG_NAME = "parent" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(parentGroupId: String) = arrayOf(TAG_NAME, parentGroupId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt index b94b093067..fe7f5a57ae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip51Lists.muteList import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -43,3 +44,9 @@ fun TagArray.mutedThreads() = mapNotNull(EventTag::parse) fun TagArray.mutedThreadIds() = mapNotNull(EventTag::parseId) fun TagArray.mutedThreadIdSet() = mapNotNullTo(mutableSetOf(), EventTag::parseId) + +fun TagArray.mutedHashtags() = mapNotNull(HashtagTag::parse) + +fun TagArray.mutedHashtagIds() = mapNotNull(HashtagTag::parseId) + +fun TagArray.mutedHashtagIdSet() = mapNotNullTo(mutableSetOf(), HashtagTag::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTag.kt new file mode 100644 index 0000000000..59e3f9fb30 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTag.kt @@ -0,0 +1,67 @@ +/* + * 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.nip51Lists.muteList.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-51 mute-list hashtag entry (`"t"`). Hides notes that carry the given + * hashtag. Per NIP-24, `t` values are conventionally lowercased. + */ +@Immutable +class HashtagTag( + val hashtag: String, +) : MuteTag { + override fun toTagArray() = assemble(hashtag) + + override fun toTagIdOnly() = assemble(hashtag) + + companion object { + const val TAG_NAME = "t" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun isTagged( + tag: Array, + hashtag: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == hashtag + + fun parse(tag: Array): HashtagTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return HashtagTag(tag[1]) + } + + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(hashtag: String) = arrayOfNotNull(TAG_NAME, hashtag) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt index 7d95b71067..df4b77a595 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt @@ -28,8 +28,8 @@ sealed interface MuteTag { fun toTagIdOnly(): Tag companion object { - fun isTagged(tag: Array) = WordTag.isTagged(tag) || UserTag.isTagged(tag) || EventTag.isTagged(tag) + fun isTagged(tag: Array) = WordTag.isTagged(tag) || UserTag.isTagged(tag) || EventTag.isTagged(tag) || HashtagTag.isTagged(tag) - fun parse(tag: Array): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) ?: EventTag.parse(tag) + fun parse(tag: Array): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) ?: EventTag.parse(tag) ?: HashtagTag.parse(tag) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index d4352ac173..ec26f341a9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -105,6 +105,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent @@ -113,6 +114,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent @@ -403,6 +405,8 @@ class EventFactory { GroupMembersEvent.KIND -> GroupMembersEvent(id, pubKey, createdAt, tags, content, sig) SupportedRolesEvent.KIND -> SupportedRolesEvent(id, pubKey, createdAt, tags, content, sig) GroupParticipantsEvent.KIND -> GroupParticipantsEvent(id, pubKey, createdAt, tags, content, sig) + GroupPinnedEvent.KIND -> GroupPinnedEvent(id, pubKey, createdAt, tags, content, sig) + UpdatePinListEvent.KIND -> UpdatePinListEvent(id, pubKey, createdAt, tags, content, sig) ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) CodeSnippetEvent.KIND -> CodeSnippetEvent(id, pubKey, createdAt, tags, content, sig) RelayFeedsListEvent.KIND -> RelayFeedsListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt new file mode 100644 index 0000000000..00d941ad25 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt @@ -0,0 +1,123 @@ +/* + * 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.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Resolving an invite's addressable coordinate per CORD-05 §2: a live `vsk=6` bundle + * opens, a `vsk=9` revocation tombstone wins even over a still-openable stale copy, + * an unknown/mis-posted sub-kind is unreadable, and an empty fetch is absent. + */ +class ConcordInviteClassifyTest { + private val owner = NostrSignerInternal(KeyPair()) + + private fun inviteFor(community: NewConcordCommunity) = + CommunityInvite( + communityId = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + communityRoot = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + ) + + /** A raw kind-33301 event at the link-signer coordinate carrying an arbitrary [vsk] wire value. */ + private fun coordinateEvent( + linkSignerPubKey: String, + vsk: String, + createdAt: Long, + content: String = "", + ) = Event( + id = "00".repeat(32), + pubKey = linkSignerPubKey, + createdAt = createdAt, + kind = ConcordInviteBundleEvent.KIND, + tags = arrayOf(arrayOf("d", ""), VskTag.TAG_NAME.let { arrayOf(it, vsk) }), + content = content, + sig = "00".repeat(64), + ) + + @Test + fun liveBundleOpens() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token) + assertTrue(status is InviteBundleStatus.Live) + assertEquals(community.communityIdHex, status.invite.communityId) + } + + @Test + fun revocationTombstoneWinsOverStaleBundle() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + // A newer vsk=9 tombstone at the same coordinate buries the still-openable bundle. + val tombstone = coordinateEvent(minted.linkSignerPubKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L) + + assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.token)) + // Order of the fetched list must not matter — newest createdAt wins regardless. + assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.token)) + } + + @Test + fun unknownSubKindIsUnreadable() = + runTest { + // A mis-posted registry (vsk=8) at the bundle coordinate — the exact shape of the + // relayop.xyz link that hung — is present but not a vsk=6 bundle we can open. + val registry = coordinateEvent("aa".repeat(32), ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable") + assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), ByteArray(16))) + } + + @Test + fun emptyFetchIsAbsent() { + assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16))) + } + + @Test + fun realRelayopBundleIsUnreadable() { + // The actual kind-33301 event behind the reported relayop.xyz/invite link (vsk=8), plus the + // 16-byte token from its #fragment. It fetches fine but can't be opened, so a compliant + // redeemer reports Unreadable instead of spinning forever. + val json = + """{"content":"ApoDjyzcHUg2imEiqw6Gsfpc2O86r+CMtMor+jc8ZlgrYwlI6CCmX7qGGEQvEJ5537nINE9H09Ro8RtEghpYgwkhdPHS274RpklFmuyLMdcoC5u1EVhppu8BrlHZ0YBfw3GX1Ui0uwy3V/J+rvrYiLhdREmwlK39JAX8sZfzCUhVtDMCgLVy03dwdpTC1Kj/ZeZJTYhJ8qmaN2273jgBTno/bFLzJlYvbANss69Tg53mljcmdSyhMlZ8z1kuenm1zkrPO5yHvi//r25tXkXb580OCkWxTmEwFzo20ntMgFnVSwVRvLZelOZt++tMevqi2Z5asvDgG7RytHP/0vLxxPzmjH0No+nITsxcmDbEweoKvSSzoc/7DYzENmfmrLXgP2KU/eE6CpTcSNaedLVKbAu9XptdtV8ruZxHjVBh1wpOwXkETEdqqvbCiR4TCNWzqbmwRKJ+acvZLBxhXcpfqmRsolaATU4sZKLs4iu92YpMIuUDh2Pquu0Daiz/IGnVe7BPb7E/gSd9NBFIxds6Nk1DbP8XKMRtYmWdTforUPWZqdM4EOtt8AcNpALRmsbEF26Gyd6t4/81bQPh+7WhI97lR/KkdWtKxNjjJ4CoJLgceyHuwbxXnFR23IWhzvQpBY12MBeYOw9oizvEzEGhEqpUns6LkH2sUNRRXbneNNvVgCEk6BK7j6Dxi95mcGJDEtOW+coE1SjhnfrwjIsdJL7cUEyC5DHFKuvxUi0iw/1I6b3AfZV5+A1tssEE2dhDv8uw6B3/a5EfMURFDqSfmGw1btdPPJ3+yjo1yYu2BtbYa4U++GtaAJfmNPrsB9lm4YgXuwCCRSpI2+TR9H2ntWM2j3HVdXqOpg3kfX82o9KFndo2g+7vGrOAyfL1jcybluq7AxPEV6D5yBky82MjoMeS0vSM6ytYu+0jheWPwDVs/3iPTELHPeDXAZOaw76ISBvNsXcxHvFsSiZBguBr+ucZOUnazVRAYIsmm/WNcIJu+6tfbyupqFCo5wkus6lKN2RNYIH1SRIi163cdBDhTBOdZoI2WcDr+SSW2fHtZutk7fW5IkJvSuy5xlke+YW/u3uzvriAIRmVDtk/fKISKEnMj2G47JdGn6EiHf+2+XfUSuDiliJb62pPXWBupinbb9HEW0tuyPHYGACH0/GA/egr6KMgI6YSh+BWS8vniMRTkmouKCzL5Csvc+2txC9LrfodrMF2R3jFZ1nig0mYzTQ9HvhqA2Uc+YG06iZtRaU7KqH6fMZYzPbjrxVOliyXR2G6","created_at":1784122846,"id":"112701bc1541c10b92f5a105e2e1f1813e591936e20075ec6a53c8bb8d235d81","kind":33301,"pubkey":"7177ccb8e8786c152e4960765f03fbceb7419d36a26e693a6399319760e7fd30","sig":"80eb4b49d70d73d35c1026b9c06d0fab280787950b5df412ebe4cdd05fcacadb4d20419c4e732749ed2698186a321069b4329ebd93fe21d8594d7392bf6445e0","tags":[["d",""],["vsk","8"]]}""" + val event = Event.fromJson(json) + val token = "c0277c415fe2ecc901a22b2f23dca5bf".hexToByteArray() + assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), token)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt new file mode 100644 index 0000000000..6547aeb9bb --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt @@ -0,0 +1,136 @@ +/* + * 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.limits + +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.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RelayLimitsTrackerTest { + private class CapturingClient( + private val delegate: INostrClient = EmptyNostrClient(), + ) : INostrClient by delegate { + 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() = true + + override fun sendOrConnectAndSync(cmd: Command) = Unit + + override fun sendIfConnected(cmd: Command) = Unit + + override fun disconnect() = Unit + } + + private fun setup(): Pair { + val client = CapturingClient() + val limits = RelayLimitsTracker(client) + val listener = client.captured ?: error("RelayLimitsTracker did not register a listener") + return limits to listener + } + + @Test + fun cachesLimitsPerRelay() { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + assertNull(limits.get(relay.url), "No limits before any LIMITS message") + + listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = true, maxLimit = 200)) + + assertEquals(true, limits.get(relay.url)?.canWrite) + assertEquals(200, limits.get(relay.url)?.maxLimit) + assertEquals(limits.get(relay.url), limits.limitsFlow.value[relay.url]) + } + + @Test + fun laterLimitsReplaceEarlierOnes() { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = false, maxLimit = 200)) + listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = true, maxLimit = 500)) + + // A relay re-advertises LIMITS when rights change (e.g. after AUTH flips can_write). + assertEquals(true, limits.get(relay.url)?.canWrite) + assertEquals(500, limits.get(relay.url)?.maxLimit) + } + + @Test + fun tracksLimitsForDistinctRelaysIndependently() { + val (limits, listener) = setup() + val relayA = FakeRelayClient(NormalizedRelayUrl("wss://a.example/")) + val relayB = FakeRelayClient(NormalizedRelayUrl("wss://b.example/")) + + listener.onIncomingMessage(relayA, "", LimitsMessage(maxLimit = 100)) + listener.onIncomingMessage(relayB, "", LimitsMessage(maxLimit = 999)) + + assertEquals(100, limits.get(relayA.url)?.maxLimit) + assertEquals(999, limits.get(relayB.url)?.maxLimit) + assertEquals(2, limits.snapshot().size) + } + + @Test + fun dropsCachedLimitsOnDisconnect() { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onIncomingMessage(relay, "", LimitsMessage(canRead = true)) + assertTrue(limits.get(relay.url) != null) + + listener.onDisconnected(relay) + assertNull(limits.get(relay.url), "Limits are connection-scoped and cleared on disconnect") + assertTrue(limits.snapshot().isEmpty()) + } + + @Test + fun ignoresNonLimitsMessages() { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onIncomingMessage(relay, "", EoseMessage("sub1")) + + assertNull(limits.get(relay.url)) + assertTrue(limits.snapshot().isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt index 2eb20459b0..3b7e726019 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage @@ -98,4 +99,112 @@ class RelayWireErgonomicsTest { assertEquals("sub1", closed.subId) assertEquals("restricted: nope", closed.message) } + + @Test + fun limitsMessageParsesProductionPayload() { + // Real payload from wss://pipe.imwald.eu/ (NIP-22 LIMITS). + val json = + """["LIMITS",{"can_read":true,"can_write":true,"auth_for_read":true,"auth_for_write":false,""" + + """"max_message_length":262144,"max_subscriptions":10,"max_filters":10,"max_limit":200,""" + + """"max_event_tags":2000,"max_content_length":131072}]""" + val parsed = Message.fromJson(json) + assertTrue(parsed is LimitsMessage) + assertEquals(true, parsed.canRead) + assertEquals(true, parsed.canWrite) + assertEquals(true, parsed.authForRead) + assertEquals(false, parsed.authForWrite) + assertEquals(262144, parsed.maxMessageLength) + assertEquals(10, parsed.maxSubscriptions) + assertEquals(10, parsed.maxFilters) + assertEquals(200, parsed.maxLimit) + assertEquals(2000, parsed.maxEventTags) + assertEquals(131072, parsed.maxContentLength) + // Fields the relay didn't send stay null. + assertNull(parsed.minPowDifficulty) + assertNull(parsed.acceptedEventKinds) + assertNull(parsed.requiredTags) + } + + @Test + fun limitsMessageParsesArrayFields() { + val json = + """["LIMITS",{"accepted_event_kinds":[0,1,3],"blocked_event_kinds":[4],""" + + """"min_pow_difficulty":16,"created_at_msecs_ago":3600000,"created_at_msecs_ahead":60000,""" + + """"required_tags":[["t","nostr"],["p"]]}]""" + val parsed = Message.fromJson(json) + assertTrue(parsed is LimitsMessage) + assertEquals(listOf(0, 1, 3), parsed.acceptedEventKinds) + assertEquals(listOf(4), parsed.blockedEventKinds) + assertEquals(16, parsed.minPowDifficulty) + assertEquals(3600000L, parsed.createdAtMsecsAgo) + assertEquals(60000L, parsed.createdAtMsecsAhead) + assertEquals(listOf(listOf("t", "nostr"), listOf("p")), parsed.requiredTags) + } + + @Test + fun limitsMessageRoundTrips() { + val original = + LimitsMessage( + canRead = true, + canWrite = false, + authForWrite = true, + maxLimit = 500, + acceptedEventKinds = listOf(1, 30023), + requiredTags = listOf(listOf("t", "nostr")), + ) + val json = original.toJson() + assertTrue(json.startsWith("""["LIMITS",{""")) + val parsed = Message.fromJson(json) + assertTrue(parsed is LimitsMessage) + assertEquals(true, parsed.canRead) + assertEquals(false, parsed.canWrite) + assertEquals(true, parsed.authForWrite) + assertNull(parsed.authForRead) + assertEquals(500, parsed.maxLimit) + assertEquals(listOf(1, 30023), parsed.acceptedEventKinds) + assertEquals(listOf(listOf("t", "nostr")), parsed.requiredTags) + } + + @Test + fun limitsMessageParsesEmptyObject() { + val parsed = Message.fromJson("""["LIMITS",{}]""") + assertTrue(parsed is LimitsMessage) + assertNull(parsed.canRead) + assertNull(parsed.maxLimit) + } + + @Test + fun limitsMessageToleratesPayloadlessFrame() { + // A malformed ["LIMITS"] with no object must not throw; it yields an empty message. + val parsed = Message.fromJson("""["LIMITS"]""") + assertTrue(parsed is LimitsMessage) + assertNull(parsed.canRead) + assertNull(parsed.maxLimit) + } + + @Test + fun limitsMessageToleratesMistypedAndNullFields() { + // Wrong-typed and explicitly-null fields degrade to null ("unspecified"), + // never to false/0, and never throw. + val json = + """["LIMITS",{"can_write":null,"max_limit":"lots","max_filters":true,""" + + """"accepted_event_kinds":[1,"x",3],"required_tags":["oops",["t","nostr"]]}]""" + val parsed = Message.fromJson(json) + assertTrue(parsed is LimitsMessage) + assertNull(parsed.canWrite, "explicit null stays null, not false") + assertNull(parsed.maxLimit, "a string is not an int -> null, not 0") + assertNull(parsed.maxFilters, "a boolean is not an int -> null") + assertEquals(listOf(1, 3), parsed.acceptedEventKinds, "non-int array elements are skipped") + // The bare "oops" string is not a tag array -> empty; the real pair survives. + assertEquals(listOf(emptyList(), listOf("t", "nostr")), parsed.requiredTags) + } + + @Test + fun limitsMessageHasValueEquality() { + // data class equality lets StateFlow.distinctUntilChanged suppress no-op re-advertisements. + assertEquals( + LimitsMessage(canWrite = true, maxLimit = 200, acceptedEventKinds = listOf(1, 2)), + LimitsMessage(canWrite = true, maxLimit = 200, acceptedEventKinds = listOf(1, 2)), + ) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt index 621b4aef9e..8a9c930807 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt @@ -147,6 +147,28 @@ class Nip11RelayInformationBuilderTest { assertEquals(3600, info.retention?.get(1)?.time) } + @Test + fun advertisesNip29SubgroupSupport() { + val info = + relayInformation { + name = "Groups" + supports(29) + subgroups() + } + + assertEquals(true, info.nip29?.subgroups) + val json = info.toJson() + assertTrue(json.contains("\"nip29\":{\"subgroups\":true}"), json) + assertEquals(info, Nip11RelayInformation.fromJson(json)) + } + + @Test + fun omitsNip29WhenNotDeclared() { + val info = relayInformation { name = "R" } + assertNull(info.nip29) + assertTrue(!info.toJson().contains("nip29"), info.toJson()) + } + @Test fun listHelpersAreRepeatableAndCollapseWhenEmpty() { val info = diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt new file mode 100644 index 0000000000..c18b584495 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt @@ -0,0 +1,68 @@ +/* + * 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.nip29RelayGroups + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * NIP-29 group identifier: the optional `?invite=` suffix appended to a + * `kind:39000` `naddr` (extracted from the trailing chars a NIP-19 parser returns + * after the bech32 body). + */ +class GroupNAddrInviteTest { + @Test + fun parsesInviteParam() { + assertEquals("abc123", GroupNAddrInvite.parse("?invite=abc123")) + } + + @Test + fun parsesInviteAmongOtherParams() { + assertEquals("abc123", GroupNAddrInvite.parse("?foo=bar&invite=abc123")) + assertEquals("abc123", GroupNAddrInvite.parse("?invite=abc123&foo=bar")) + } + + @Test + fun nullWhenNoSuffix() { + assertNull(GroupNAddrInvite.parse(null)) + assertNull(GroupNAddrInvite.parse("")) + assertNull(GroupNAddrInvite.parse("?")) + assertNull(GroupNAddrInvite.parse("?other=1")) + } + + @Test + fun nullWhenInviteEmpty() { + assertNull(GroupNAddrInvite.parse("?invite=")) + } + + @Test + fun acceptsBareInviteWithoutQuestionMark() { + // Defensive: if an upstream parser drops the `?`, a bare `invite=` still resolves. + assertEquals("abc123", GroupNAddrInvite.parse("invite=abc123")) + } + + @Test + fun ignoresLeadingBech32RemainderWithoutQuery() { + // A plain trailing word (not a query) carries no invite. + assertNull(GroupNAddrInvite.parse("someword")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt new file mode 100644 index 0000000000..3cdd3fbe2d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt @@ -0,0 +1,83 @@ +/* + * 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.nip29RelayGroups + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * NIP-29 message-pinning wire format (PR #2379): the kind-9010 `update-pin-list` + * moderation write and the relay-signed kind-39005 pinned-list read side. Verifies + * both are dispatched to the right class by [EventFactory] and that the `h`/`d` group + * scope plus the ordered `e` id list round-trip through build → parse. + */ +class PinEventsTest { + private val relaySelf = "aa".repeat(32) + private val gid = "0123456789abcdef" + private val id1 = "11".repeat(32) + private val id2 = "22".repeat(32) + private val id3 = "33".repeat(32) + + @Test + fun groupPinnedEventParsesGroupAndOrderedIds() { + val tags = arrayOf(arrayOf("d", gid), arrayOf("e", id1), arrayOf("e", id2), arrayOf("e", id3)) + val event: Event = EventFactory.create("00".repeat(32), relaySelf, 100, GroupPinnedEvent.KIND, tags, "", "22".repeat(64)) + + assertEquals(true, event is GroupPinnedEvent) + event as GroupPinnedEvent + assertEquals(gid, event.groupId()) + assertEquals(listOf(id1, id2, id3), event.pinnedEventIds()) + } + + @Test + fun updatePinListEventParsesGroupAndIds() { + val tags = arrayOf(arrayOf("h", gid), arrayOf("e", id1), arrayOf("e", id2)) + val event: Event = EventFactory.create("00".repeat(32), relaySelf, 100, UpdatePinListEvent.KIND, tags, "", "22".repeat(64)) + + assertEquals(true, event is UpdatePinListEvent) + event as UpdatePinListEvent + assertEquals(gid, event.groupId()) + assertEquals(listOf(id1, id2), event.pinnedEventIds()) + } + + @Test + fun updatePinListBuildCarriesHTagAndFullList() { + val template = UpdatePinListEvent.build(gid, listOf(id1, id2)) + + assertEquals(UpdatePinListEvent.KIND, template.kind) + assertEquals(gid, template.tags.firstOrNull { it[0] == GroupIdTag.TAG_NAME }?.getOrNull(1)) + assertEquals(listOf(id1, id2), template.tags.filter { it[0] == "e" }.map { it[1] }) + } + + @Test + fun groupPinnedBuildCarriesDTagAndFullList() { + val template = GroupPinnedEvent.build(gid, listOf(id1, id2, id3)) + + assertEquals(GroupPinnedEvent.KIND, template.kind) + assertEquals(gid, template.tags.firstOrNull { it[0] == "d" }?.getOrNull(1)) + assertEquals(listOf(id1, id2, id3), template.tags.filter { it[0] == "e" }.map { it[1] }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt new file mode 100644 index 0000000000..cc4360e828 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt @@ -0,0 +1,196 @@ +/* + * 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.nip29RelayGroups + +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * NIP-29 §Subgroups: the `parent`/`child` tags on `kind:39000` group metadata and + * `kind:9002` edit-metadata, plus [SubgroupTree] assembly of a relay's flat group + * set into the parent/child hierarchy (ordering, orphan-as-root, cycle safety). + */ +class SubgroupTest { + private val relaySelf = "aa".repeat(32) + private val sig = "bb".repeat(64) + private val id = "00".repeat(32) + + private fun metadata( + groupId: String, + parent: String? = null, + children: List = emptyList(), + createdAt: Long = 100, + ): GroupMetadataEvent { + val template = GroupMetadataEvent.build(groupId, name = groupId, parent = parent, children = children, createdAt = createdAt) + return EventFactory.create(id, relaySelf, template.createdAt, GroupMetadataEvent.KIND, template.tags, "", sig) as GroupMetadataEvent + } + + @Test + fun rootGroupHasNoParent() { + val root = metadata("tech", children = listOf("nostr")) + assertNull(root.parent()) + assertTrue(root.isRoot()) + assertEquals(listOf("nostr"), root.children()) + } + + @Test + fun subgroupCarriesParentAndChildOrder() { + val sub = metadata("nostr", parent = "tech", children = listOf("nip29", "nips")) + assertEquals("tech", sub.parent()) + assertEquals(false, sub.isRoot()) + assertEquals(listOf("nip29", "nips"), sub.children()) + } + + @Test + fun editMetadataRoundTripsParentAndChildren() { + val template = EditMetadataEvent.build("nostr", name = "Nostr", parent = "social", children = listOf("nip29")) + val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent + + assertEquals("nostr", event.groupId()) + assertEquals("social", event.parent()) + assertEquals(listOf("nip29"), event.children()) + } + + @Test + fun editMetadataWithoutParentRoots() { + val template = EditMetadataEvent.build("nostr", name = "Nostr") + val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent + assertNull(event.parent()) + } + + @Test + fun buildsNestedTree() { + val tree = + SubgroupTree.build( + listOf( + metadata("tech", children = listOf("nostr")), + metadata("nostr", parent = "tech", children = listOf("nip29")), + metadata("nip29", parent = "nostr"), + ), + ) + + assertEquals(1, tree.size) + val tech = tree[0] + assertEquals("tech", tech.metadata.groupId()) + assertEquals(1, tech.children.size) + val nostr = tech.children[0] + assertEquals("nostr", nostr.metadata.groupId()) + assertEquals( + "nip29", + nostr.children + .single() + .metadata + .groupId(), + ) + } + + @Test + fun ordersSiblingsByParentChildTags() { + // Parent lists children in b, a, c order — the tree must follow that, not insertion order. + val tree = + SubgroupTree.build( + listOf( + metadata("root", children = listOf("b", "a", "c")), + metadata("a", parent = "root"), + metadata("b", parent = "root"), + metadata("c", parent = "root"), + ), + ) + + assertEquals(listOf("b", "a", "c"), tree.single().children.map { it.metadata.groupId() }) + } + + @Test + fun unlistedChildrenComeAfterListedOnes() { + val tree = + SubgroupTree.build( + listOf( + metadata("root", children = listOf("a")), + metadata("a", parent = "root"), + // "b" points at root but root hasn't listed it yet + metadata("b", parent = "root"), + ), + ) + + assertEquals(listOf("a", "b"), tree.single().children.map { it.metadata.groupId() }) + } + + @Test + fun groupWithMissingParentBecomesRoot() { + // "nostr" declares a parent that isn't in the set — surface it as a root, not dropped. + val tree = SubgroupTree.build(listOf(metadata("nostr", parent = "ghost"))) + assertEquals(listOf("nostr"), tree.map { it.metadata.groupId() }) + } + + @Test + fun multipleRootsAreReturned() { + val tree = + SubgroupTree.build( + listOf( + metadata("tech"), + metadata("food"), + metadata("pizza", parent = "food"), + ), + ) + + assertEquals(setOf("tech", "food"), tree.map { it.metadata.groupId() }.toSet()) + } + + @Test + fun cycleDoesNotLoopForever() { + // Malformed data: a <-> b point at each other. A compliant relay rejects this, + // but the assembler must terminate and still surface the groups. + val tree = + SubgroupTree.build( + listOf( + metadata("a", parent = "b"), + metadata("b", parent = "a"), + ), + ) + + val allIds = mutableSetOf() + + fun collect(node: GroupTreeNode) { + allIds.add(node.metadata.groupId()) + node.children.forEach(::collect) + } + tree.forEach(::collect) + assertTrue(allIds.containsAll(setOf("a", "b"))) + } + + @Test + fun newestMetadataWinsForSameId() { + val tree = + SubgroupTree.build( + listOf( + metadata("g", children = listOf("old"), createdAt = 100), + metadata("g", children = listOf("new"), createdAt = 200), + ), + ) + + assertEquals(listOf("new"), tree.single().metadata.children()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTagTest.kt new file mode 100644 index 0000000000..8a0ec89c39 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTagTest.kt @@ -0,0 +1,81 @@ +/* + * 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.nip51Lists.muteList.tags + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HashtagTagTest { + private val hashtag = "bitcoin" + + @Test fun isTagged_acceptsHashtag() { + assertTrue(HashtagTag.isTagged(arrayOf("t", hashtag))) + } + + @Test fun isTagged_rejectsEmpty() { + assertFalse(HashtagTag.isTagged(arrayOf("t", ""))) + } + + @Test fun isTagged_rejectsWrongPrefix() { + assertFalse(HashtagTag.isTagged(arrayOf("word", hashtag))) + } + + @Test fun parse_extractsHashtag() { + val tag = assertNotNull(HashtagTag.parse(arrayOf("t", hashtag))) + assertEquals(hashtag, tag.hashtag) + } + + @Test fun parse_rejectsEmpty() { + assertNull(HashtagTag.parse(arrayOf("t", ""))) + } + + @Test fun parse_rejectsWrongPrefix() { + assertNull(HashtagTag.parse(arrayOf("p", hashtag))) + } + + @Test fun parseId_extractsValue() { + assertEquals(hashtag, HashtagTag.parseId(arrayOf("t", hashtag))) + } + + @Test fun toTagArray_roundTrips() { + val parsed = assertNotNull(HashtagTag.parse(HashtagTag(hashtag).toTagArray())) + assertEquals(hashtag, parsed.hashtag) + } + + @Test fun muteTagCompanion_parsesHashtagTag() { + val parsed = assertNotNull(MuteTag.parse(arrayOf("t", hashtag))) + assertTrue(parsed is HashtagTag) + } + + @Test fun muteTagCompanion_isTaggedRecognizesHashtagTag() { + assertTrue(MuteTag.isTagged(arrayOf("t", hashtag))) + } + + @Test fun muteTagCompanion_doesNotConfuseHashtagWithWord() { + // "t" must parse as a hashtag, not a word; "word" must parse as a word. + assertTrue(MuteTag.parse(arrayOf("t", hashtag)) is HashtagTag) + assertTrue(MuteTag.parse(arrayOf("word", hashtag)) is WordTag) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt new file mode 100644 index 0000000000..33b6a439bd --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt @@ -0,0 +1,66 @@ +/* + * 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.commands.toClient + +import com.fasterxml.jackson.databind.JsonNode + +class LimitsDeserializer { + companion object { + // Type-checked readers: a missing field, an explicit JSON null, or a + // wrong-typed value all yield null ("unspecified — keep the previous + // value") rather than coercing to false/0. Keeps this in step with the + // kotlinx path (LimitsKSerializer) used on iOS/native. + private fun JsonNode.bool(field: String): Boolean? = get(field)?.takeIf { it.isBoolean }?.booleanValue() + + private fun JsonNode.int(field: String): Int? = get(field)?.takeIf { it.isNumber }?.intValue() + + private fun JsonNode.long(field: String): Long? = get(field)?.takeIf { it.isNumber }?.longValue() + + private fun JsonNode.intList(field: String): List? = get(field)?.takeIf { it.isArray }?.mapNotNull { it.takeIf { n -> n.isNumber }?.intValue() } + + private fun JsonNode.requiredTags(field: String): List>? = + get(field)?.takeIf { it.isArray }?.map { tag -> + if (tag.isArray) tag.mapNotNull { it.takeIf { n -> n.isValueNode }?.asText() } else emptyList() + } + + fun fromJson(jsonObject: JsonNode): LimitsMessage = + LimitsMessage( + canWrite = jsonObject.bool("can_write"), + canRead = jsonObject.bool("can_read"), + authForRead = jsonObject.bool("auth_for_read"), + authForWrite = jsonObject.bool("auth_for_write"), + acceptedEventKinds = jsonObject.intList("accepted_event_kinds"), + blockedEventKinds = jsonObject.intList("blocked_event_kinds"), + minPowDifficulty = jsonObject.int("min_pow_difficulty"), + maxMessageLength = jsonObject.int("max_message_length"), + maxSubscriptions = jsonObject.int("max_subscriptions"), + maxFilters = jsonObject.int("max_filters"), + maxLimit = jsonObject.int("max_limit"), + maxEventTags = jsonObject.int("max_event_tags"), + maxContentLength = jsonObject.int("max_content_length"), + createdAtMsecsAgo = jsonObject.long("created_at_msecs_ago"), + createdAtMsecsAhead = jsonObject.long("created_at_msecs_ahead"), + filterRateLimit = jsonObject.long("filter_rate_limit"), + publishingRateLimit = jsonObject.long("publishing_rate_limit"), + requiredTags = jsonObject.requiredTags("required_tags"), + ) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt index 09e31365e0..7e45082421 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt @@ -103,6 +103,16 @@ class MessageDeserializer : StdDeserializer(Message::class.java) { ) } + LimitsMessage.LABEL -> { + // Tolerate a payload-less ["LIMITS"] frame instead of throwing. + if (jp.nextToken() == JsonToken.START_OBJECT) { + val result: JsonNode = jp.codec.readTree(jp) + LimitsDeserializer.fromJson(result) + } else { + LimitsMessage() + } + } + NegMsgMessage.LABEL -> { NegMsgMessage( subId = jp.nextTextValue(), diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index 9048fe774c..86274bf1e6 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -80,6 +80,47 @@ class MessageSerializer : StdSerializer(Message::class.java) { gen.writeString(msg.subId) } + is LimitsMessage -> { + // LIMITS wire format: ["LIMITS", { }]. Only + // the fields the relay set are emitted; absent limits stay absent. + gen.writeStartObject() + msg.canWrite?.let { gen.writeBooleanField("can_write", it) } + msg.canRead?.let { gen.writeBooleanField("can_read", it) } + msg.authForRead?.let { gen.writeBooleanField("auth_for_read", it) } + msg.authForWrite?.let { gen.writeBooleanField("auth_for_write", it) } + msg.acceptedEventKinds?.let { + gen.writeArrayFieldStart("accepted_event_kinds") + it.forEach { kind -> gen.writeNumber(kind) } + gen.writeEndArray() + } + msg.blockedEventKinds?.let { + gen.writeArrayFieldStart("blocked_event_kinds") + it.forEach { kind -> gen.writeNumber(kind) } + gen.writeEndArray() + } + msg.minPowDifficulty?.let { gen.writeNumberField("min_pow_difficulty", it) } + msg.maxMessageLength?.let { gen.writeNumberField("max_message_length", it) } + msg.maxSubscriptions?.let { gen.writeNumberField("max_subscriptions", it) } + msg.maxFilters?.let { gen.writeNumberField("max_filters", it) } + msg.maxLimit?.let { gen.writeNumberField("max_limit", it) } + msg.maxEventTags?.let { gen.writeNumberField("max_event_tags", it) } + msg.maxContentLength?.let { gen.writeNumberField("max_content_length", it) } + msg.createdAtMsecsAgo?.let { gen.writeNumberField("created_at_msecs_ago", it) } + msg.createdAtMsecsAhead?.let { gen.writeNumberField("created_at_msecs_ahead", it) } + msg.filterRateLimit?.let { gen.writeNumberField("filter_rate_limit", it) } + msg.publishingRateLimit?.let { gen.writeNumberField("publishing_rate_limit", it) } + msg.requiredTags?.let { + gen.writeArrayFieldStart("required_tags") + it.forEach { tag -> + gen.writeStartArray() + tag.forEach { part -> gen.writeString(part) } + gen.writeEndArray() + } + gen.writeEndArray() + } + gen.writeEndObject() + } + is NegMsgMessage -> { gen.writeString(msg.subId) gen.writeString(msg.message) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt index 959b4de530..40e7aa5884 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage @@ -498,6 +499,96 @@ class KotlinSerializationMapperTest { assertEquals(jacksonJson, kotlinJson) } + @Test + fun serializeLimitsMessage_matchesJackson() { + val msg = + LimitsMessage( + canWrite = true, + canRead = true, + authForRead = true, + authForWrite = false, + acceptedEventKinds = listOf(0, 1, 3), + blockedEventKinds = listOf(4), + minPowDifficulty = 16, + maxMessageLength = 262144, + maxSubscriptions = 10, + maxFilters = 10, + maxLimit = 200, + maxEventTags = 2000, + maxContentLength = 131072, + createdAtMsecsAgo = 3600000L, + createdAtMsecsAhead = 60000L, + filterRateLimit = 100L, + publishingRateLimit = 500L, + requiredTags = listOf(listOf("t", "nostr"), listOf("p")), + ) + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeLimitsMessage() { + val json = + """["LIMITS",{"can_read":true,"can_write":true,"auth_for_read":true,"auth_for_write":false,""" + + """"max_subscriptions":10,"max_limit":200}]""" + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + assertTrue(deserialized is LimitsMessage) + assertEquals(true, deserialized.canRead) + assertEquals(true, deserialized.canWrite) + assertEquals(true, deserialized.authForRead) + assertEquals(false, deserialized.authForWrite) + assertEquals(10, deserialized.maxSubscriptions) + assertEquals(200, deserialized.maxLimit) + assertNull(deserialized.maxFilters) + } + + @Test + fun deserializeLimitsMessageToleratesMistypedAndMissingPayload() { + // The kotlinx path is the iOS/native incoming parser; a mistyped field or a + // payload-less ["LIMITS"] must degrade to null / empty, matching Jackson, + // rather than throwing. + val mistyped = + KotlinSerializationMapper.fromJsonToMessage( + """["LIMITS",{"can_write":null,"max_limit":"lots","accepted_event_kinds":[1,"x",3],"required_tags":["oops",["t","nostr"]]}]""", + ) + assertTrue(mistyped is LimitsMessage) + assertNull(mistyped.canWrite) + assertNull(mistyped.maxLimit) + assertEquals(listOf(1, 3), mistyped.acceptedEventKinds) + assertEquals(listOf(emptyList(), listOf("t", "nostr")), mistyped.requiredTags) + + val payloadless = KotlinSerializationMapper.fromJsonToMessage("""["LIMITS"]""") + assertTrue(payloadless is LimitsMessage) + assertNull(payloadless.maxLimit) + } + + @Test + fun crossDeserializationLimitsMessage() { + val msg = + LimitsMessage( + canRead = true, + canWrite = false, + maxLimit = 500, + acceptedEventKinds = listOf(1, 30023), + requiredTags = listOf(listOf("t", "nostr")), + ) + + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(jacksonJson) + assertTrue(kotlinDeserialized is LimitsMessage) + assertEquals(msg.maxLimit, kotlinDeserialized.maxLimit) + assertEquals(msg.acceptedEventKinds, kotlinDeserialized.acceptedEventKinds) + assertEquals(msg.requiredTags, kotlinDeserialized.requiredTags) + + val kotlinJson = KotlinSerializationMapper.toJson(msg) + val jacksonDeserialized = JacksonMapper.fromJsonToMessage(kotlinJson) + assertTrue(jacksonDeserialized is LimitsMessage) + assertEquals(msg.maxLimit, jacksonDeserialized.maxLimit) + assertEquals(msg.acceptedEventKinds, jacksonDeserialized.acceptedEventKinds) + assertEquals(msg.requiredTags, jacksonDeserialized.requiredTags) + } + @Test fun crossDeserializationMessages() { val messages =