Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
This commit is contained in:
Claude
2026-07-16 23:52:28 +00:00
122 changed files with 6590 additions and 644 deletions
+7 -3
View File
@@ -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-<ver>-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"
+11
View File
@@ -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." />
</service>
<service
android:name=".service.notifications.NotificationServiceTileService"
android:icon="@drawable/amethyst_service"
android:label="@string/always_on_notif_tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<receiver
android:name=".service.notifications.BootCompletedReceiver"
android:exported="false">
@@ -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()
}
@@ -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<String, AccountSettings?> = 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<Boolean> by lazy {
MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true))
}
fun notificationServiceEnabledFlow(): StateFlow<Boolean> = 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 =
@@ -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/<naddr>#<fragment>`): 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<String> = emptyList(),
geohashes: List<String> = 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<HexKey>,
) {
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<String> = emptyList(),
geohashes: List<String> = emptyList(),
parent: String? = channel.parentGroupId(),
children: List<String> = 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))
}
@@ -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
}
@@ -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)
}
@@ -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,
)
@@ -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) {
@@ -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() {
@@ -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
}
@@ -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 (<service android:label/android:icon>),
// 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
}
}
}
@@ -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<Boolean> {
// 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
@@ -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=<code>`; 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)
}
/**
@@ -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=<code>`; 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
@@ -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)
@@ -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)
@@ -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<Note>, 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<String?>? = 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<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = 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<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = 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(),
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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<HexKey, Map<HexKey, Long>>()) })
.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<String?>(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<HexKey>,
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<HexKey>,
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?,
@@ -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<HexKey>,
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)
}
}
}
}
}
}
@@ -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)
}
}
@@ -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))
}
}
}
@@ -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<Int> {
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<HexKey> {
val latestByAuthor = HashMap<HexKey, Long>()
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 }
}
@@ -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,
)
}
}
@@ -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()
}
}
}
@@ -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<RelayBasedFilter> =
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<RelayBasedFilter> {
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
}
@@ -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=<code>` 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()
},
) {
@@ -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<String?>(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) {
@@ -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<String>,
)
@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
}
@@ -226,6 +226,10 @@ private fun RelayGroupMetadataScaffold(
Spacer(Modifier.height(16.dp))
GroupMetadataFields(viewModel)
Spacer(Modifier.height(16.dp))
ParentGroupSection(viewModel, accountViewModel)
}
}
}
@@ -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<String?>(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<SelectedMedia?>(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(),
)
}
}
@@ -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<RelayGroupChannel>(),
relay,
relayInfo,
forbidden,
) {
value = pickCandidates(accountViewModel, relay, relayInfo, forbidden)
LocalCache
.observeEvents<GroupMetadataEvent>(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<String>,
): List<RelayGroupChannel> =
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<String> {
val result = mutableSetOf<String>()
val queue = ArrayDeque<String>()
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
}
@@ -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
}
}
@@ -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,
)
}
}
}
}
@@ -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))
@@ -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()
@@ -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)
}
},
)
}
}
}
@@ -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<List<Pair<AccountInfo, AccountSettings>>>(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
+102 -2
View File
@@ -97,6 +97,7 @@
<string name="security_filters">सुरक्षा छलनियाँ</string>
<string name="log_out">निर्गमनांकन</string>
<string name="show_more">अधिक दिखाएँ</string>
<string name="show_less">अल्प दिखाएँ</string>
<string name="lightning_invoice">लैटनिंग चालान</string>
<string name="invoice_expired">समयसीमा समाप्त</string>
<string name="clink_lightning_offer">क्लिंक॰ प्रस्ताव</string>
@@ -297,6 +298,9 @@
<string name="loading_account">लेखा जानकारी प्राप्त की जा रही है</string>
<string name="concord_redeeming_invite">आमन्त्रण चुकाव…</string>
<string name="concord_invite_failed">आमन्त्रण अप्राप्त। सम्भाव्यतः योजक निष्क्रिय है अथवा इसके पुनःप्रसारक अनभिगम्य।</string>
<string name="concord_invite_failed_invalid">यह आमन्त्रण योजक अमान्य है अथवा इस लेखा द्वारा खोला नहीं जा सकता।</string>
<string name="concord_invite_failed_incompatible">यह आमन्त्रण बनाया गया एक नवीनतर क्रमक संस्करण से तथा यहाँ नहीं खोला जा सकता अभी। एक अद्यतनकृत योजक के लिए पूछें अथवा अद्यतन पश्चात पुनःप्रयास करें।</string>
<string name="concord_invite_failed_revoked">यह आमन्त्रण योजक निराकृत है तथा अनुपयुक्तव्य। नए के लिए पूछें।</string>
<string name="concord_home_title">कोंकोर्ड॰ प्रणाली</string>
<string name="concord_home_empty">आप किसी भी कोंकोर्ड प्रणाली से जुडे नहीं अब तक। एक बनाएँ अथवा आमन्त्रण योजक खोलें।</string>
<string name="concord_channels_empty">कोई प्रणाली नहीं अब तक।</string>
@@ -329,6 +333,11 @@
<item quantity="one">%1$d सदस्य</item>
<item quantity="other">%1$d सदस्य</item>
</plurals>
<plurals name="concord_unread_messages">
<item quantity="one">%1$d नया सन्देश</item>
<item quantity="other">%1$d नए सन्देश</item>
</plurals>
<string name="concord_channel_no_messages">कोई सन्देश नहीं अब तक</string>
<string name="concord_create_action">बनाएँ</string>
<string name="concord_invite_action">लोगों को आमन्त्रण</string>
<string name="concord_invite_title">आमन्त्रण योजक</string>
@@ -1861,6 +1870,63 @@
<string name="messages_create_public_private_chat_description">नया निजी अथवा सार्वजनिक झुण्ड</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="new_conversation_title">एक वार्तालाप आरम्भ करें</string>
<string name="new_conversation_best_for">इस के लिए सबसे अच्छा</string>
<string name="new_conversation_pros">अच्छा</string>
<string name="new_conversation_cons">समझौते</string>
<string name="new_conversation_section_direct">निजी</string>
<string name="new_conversation_section_encrypted">रहस्यीकृत समूह</string>
<string name="new_conversation_section_relay">पुनःप्रसारक अवगत</string>
<string name="new_conversation_dm_title">निजी सन्देश</string>
<string name="new_conversation_dm_tagline">सीधे सन्देश आरम्भ से अन्त तक रहस्यीकृत</string>
<string name="new_conversation_dm_chip">एक प्रति एक। पाँच शालाएँ</string>
<string name="new_conversation_dm_best">एक प्रति एक तथा लघु समूह चर्चाएँ आपके परिचित लोगों के साथ।</string>
<string name="new_conversation_dm_cta">नया सन्देश</string>
<string name="new_conversation_dm_pro_1">एक प्रति एक तथा लघु समूह</string>
<string name="new_conversation_dm_pro_2">आरम्भ से अन्त तक रहस्यीकृत</string>
<string name="new_conversation_dm_con_1">तत्पश्चात प्रयोक्ताओं को नहीं जोड सकते</string>
<string name="new_conversation_marmot_title">मार्मोट॰ समूह</string>
<string name="new_conversation_marmot_tagline">बृहत्तर निजी समूह</string>
<string name="new_conversation_marmot_chip">यन्त्रबद्ध</string>
<string name="new_conversation_marmot_best">बृहत्तर निजी समूह जो लघुसमूह सीधेसन्देश से उद्भूत हैं।</string>
<string name="new_conversation_marmot_cta">समूह बनाएँ</string>
<string name="new_conversation_marmot_pro_1">लघु सीधेसन्देश मापदण्डस्तर पार करता है</string>
<string name="new_conversation_marmot_pro_2">रहस्यीकृत समूह चर्चा</string>
<string name="new_conversation_marmot_con_1">यन्त्रबद्ध। अदृश्य यदि आप अन्यत्र प्रवेशांकन करें</string>
<string name="new_conversation_concord_title">कोंकोर्ड समुदाय</string>
<string name="new_conversation_concord_tagline">समुदाय प्रणाली अनुसार विभाजित</string>
<string name="new_conversation_concord_chip">कार्यशालाएँ</string>
<string name="new_conversation_concord_best">विशाल समुदाय। प्रणालियों अथवा कार्यप्रवाहों अनुसार व्यवस्थित।</string>
<string name="new_conversation_concord_cta">समुदाय बनाएँ</string>
<string name="new_conversation_concord_pro_1">विशाल समुदायों तक का मापदण्डस्तर</string>
<string name="new_conversation_concord_pro_2">प्रणाली अथवा कार्यप्रवाह अनुसार विभाजित</string>
<string name="new_conversation_concord_con_1">स्थापनार्थ और अधिक</string>
<string name="new_conversation_public_chat_title">सार्वजनिक चर्चा</string>
<string name="new_conversation_public_chat_tagline">सार्वजनिक सन्देश एक पुनःप्रसारक पर</string>
<string name="new_conversation_public_chat_chip">नियमनरहित</string>
<string name="new_conversation_public_chat_best">खुले सार्वजनिक शालाएँ जहाँ कोई भी पत्र प्रकाशित कर सकता है।</string>
<string name="new_conversation_public_chat_cta">नयी सार्वजनिक चर्चा बनाएँ</string>
<string name="new_conversation_public_chat_pro_1">कोई भी जुड सकता है</string>
<string name="new_conversation_public_chat_pro_2">सरल तथा आविष्करणशक्य</string>
<string name="new_conversation_public_chat_con_1">केवल सार्वजनिक</string>
<string name="new_conversation_public_chat_con_2">नियमनरहित</string>
<string name="new_conversation_relay_group_title">पुनःप्रसारक समूह</string>
<string name="new_conversation_relay_group_tagline">पुनःप्रसारक संचालित। सार्वजनिक अथवा निजी</string>
<string name="new_conversation_relay_group_chip">नियमनयुक्त</string>
<string name="new_conversation_relay_group_best">अपने निवास पुनःप्रसारक द्वारा नियमनकृत समूह।</string>
<string name="new_conversation_relay_group_cta">पुनःप्रसारक समूह वीक्षण</string>
<string name="new_conversation_relay_group_pro_1">पुनःप्रसारक द्वारा नियमनकृत</string>
<string name="new_conversation_relay_group_pro_2">सार्वजनिक अथवा निजी</string>
<string name="new_conversation_relay_group_con_1">उस एक पुनःप्रसारक से बद्ध</string>
<string name="new_conversation_ephemeral_title">नश्वर चर्चा</string>
<string name="new_conversation_ephemeral_tagline">जो भी संयोजित हो। अभी इस समय</string>
<string name="new_conversation_ephemeral_chip">वर्तमान अभी</string>
<string name="new_conversation_ephemeral_best">सामयिक चर्चा किसी से भी जो अभी संयोजित हो।</string>
<string name="new_conversation_ephemeral_cta">चर्चा आरम्भ करें</string>
<string name="new_conversation_ephemeral_pro_1">जो भी संयोजित हो अभी उनसे बात करें</string>
<string name="new_conversation_ephemeral_pro_2">कुछ भी अभिलेखित नहीं</string>
<string name="new_conversation_ephemeral_con_1">कोई इतिहास नहीं</string>
<string name="new_conversation_ephemeral_con_2">पुनःप्रसारक सीमित</string>
<string name="relay_groups_title">पुनःप्रसारक समूह</string>
<string name="relay_group_view_inline">पंक्ति में</string>
<string name="relay_group_view_grouped">पुनःप्रसारक अनुसार</string>
@@ -1905,6 +1971,10 @@
<string name="relay_group_menu_members">सदस्य सूची</string>
<string name="relay_group_menu_edit">समूह सम्पादन</string>
<string name="relay_group_threads_title">सूत्र</string>
<string name="relay_group_pin_message">सन्देश टाँकें</string>
<string name="relay_group_unpin_message">सन्देश से टाँका हटाएँ</string>
<string name="relay_group_pinned_label">टँकित</string>
<string name="relay_group_pinned_content_description">टँकित सन्देश</string>
<string name="relay_group_open">समूह खोलें</string>
<string name="relay_group_channels_empty">इस पुनःप्रसारक पर कोई समूह नहीं अब तक।</string>
<string name="relay_group_channels_not_nip29">यह पुनःप्रसारक समूहों का अवलम्बन करने का विज्ञापन नहीं करता (निप॰२९)। इसलिए सम्भावना है कि कोई समूह यहाँ उपलब्ध नहीं।</string>
@@ -2039,6 +2109,8 @@
<string name="no_blossom_apps_found_description">ब्लोस्सम॰ क्रमक प्राप्त नहीं। कृपया एक स्थानीय ब्लोस्सम॰ क्रमक की स्थापना करें इस अभिलेख को देखने के लिए</string>
<string name="hidden_words">छिपाए गये शब्द</string>
<string name="hide_new_word_label">नया शब्द अथवा वाक्य छिपाएँ</string>
<string name="mute_hashtag">विषयसूचक मौन करें</string>
<string name="unmute_hashtag">विषयसूचक मौन अवस्था हटाएँ</string>
<string name="settings_muted_threads_title">मौनकृत सूत्र</string>
<string name="settings_muted_threads_empty">कोई मौनकृत सूत्र नहीं</string>
<string name="settings_muted_threads_unknown">अज्ञात सूत्र। %1$s</string>
@@ -2565,8 +2637,23 @@
<string name="bottom_bar_settings">निचली मार्गदर्शन पट्टी</string>
<string name="bottom_bar_settings_description">क्रम पुनःव्यवस्थित करने के लिए खींचें। विकल्पान्तरण करें वस्तु को जोडने अथवा हटाने के लिए निचली पट्टी से। शून्य वस्तु हो तो निचली पट्टी अदृश्य रहेगी।</string>
<string name="bottom_bar_settings_available">उपलब्ध</string>
<string name="bottom_bar_settings_pinned">आपकी निचली पट्टी</string>
<string name="bottom_bar_settings_pinned_empty">कोई वस्तु टँकित नहीं। निचली पट्टी अदृश्य रहेगा जब तक कि आप न्यूनतम एक को जोडते नहीं।</string>
<string name="bottom_bar_settings_reorder">क्रम पुनःव्यवस्था</string>
<string name="bottom_bar_settings_expand">विकल्प दिखाएँ</string>
<string name="bottom_bar_settings_no_favorites">कोई प्रियचिह्नित नहीं। एक क्रमक को ताराचिह्नित करें जालसंचारक में यहाँ जोडने के लिए।</string>
<string name="bottom_bar_settings_no_groups">कोई समूह नहीं जिससे जुडे हुए हैं अब तक।</string>
<string name="bottom_bar_settings_reorder_hint">खीचें पुनःव्यवस्थित करने के लिए। व्यत्यस्तचिह्न दबाएँ हटाने के लिए</string>
<string name="bottom_bar_settings_add">जोडें</string>
<string name="bottom_bar_settings_added">जोडा गया</string>
<string name="bottom_bar_settings_remove">हटाएँ</string>
<string name="bottom_bar_settings_restore_default">मूलविकल्प पुनःस्थापित करें</string>
<string name="bottom_bar_category_main">मुख्‍य</string>
<string name="bottom_bar_category_chats">चर्चाएँ तथा समूह</string>
<string name="bottom_bar_category_you">आप</string>
<string name="bottom_bar_category_feeds">सूचनावलियाँ</string>
<string name="bottom_bar_category_apps">क्रमक तथा जाल</string>
<string name="bottom_bar_category_other">अन्य</string>
<string name="home_tabs_settings">मुख्यपटल पृष्ठसूचक</string>
<string name="home_tabs_settings_description">चयन करें किन पृष्ठसूचक दिखने चाहिए मुख्यपटल पर। जब एक ही सक्रिय है तब पृष्ठसूचक पट्टी छुपाई जाएगी।</string>
<string name="home_tab_everything">सभी</string>
@@ -2636,6 +2723,15 @@
<string name="remove_location">स्थान हटाएँ</string>
<string name="add_content_warning">विषयवस्तु चेतावनी जोडें</string>
<string name="remove_content_warning">विषयवस्तु चेतावनी हटाएँ</string>
<string name="chat_system_created_channel">%1$s ने %2$s प्रणाली बनाया</string>
<string name="chat_system_created_channel_unnamed">%1$s ने प्रणाली बनाया</string>
<string name="chat_system_updated_channel">%1$s ने प्रणाली परिचय का अद्यतन किया</string>
<string name="chat_delivery_details_title">सन्देश वितरण</string>
<string name="close">आवृत</string>
<string name="chat_delivery_pending">किसी एक पुनःप्रसारक द्वारा इस सन्देश की स्वीकृति की प्रतीक्षा</string>
<string name="chat_delivery_no_relay_info">इस सन्देश के लिए कोई पुनःप्रसारक जानकारी नहीं</string>
<string name="chat_delivery_accepted">न्यूनतम एक पुनःप्रसारक द्वारा स्वीकृत</string>
<string name="chat_delivery_delivered_all">सभी प्राप्तकर्ताओं के पुनःप्रसारकों को भेजा गया</string>
<string name="add_expiration_date">समापन दिनांक जोडें</string>
<string name="remove_expiration_date">समापन दिनांक हटाएँ</string>
<string name="expiration_date_label">समापन दिनांक</string>
@@ -2963,7 +3059,7 @@
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">क्या आप निकटकालिक क्रमदोष सूचनापत्र एक सीधे सन्देश में अमेथिस्ट को भेजना चाहते हैं। कोई व्यक्तिगत जानकारी बाँटी नहीं जाएगी</string>
<string name="crashreport_found_send">भेजें</string>
<string name="resource_usage_title">क्रमक संसाधन उपयोग</string>
<string name="resource_usage_search_keywords">विद्युत्कोष तथ्य उपयोग उपभोग ऊर्जा जाल निदान लेखापुस्तक</string>
<string name="resource_usage_search_keywords">विद्युत्कोष तथ्य उपयोग उपभोग ऊर्जा जाल निदानतन्त्र लेखापुस्तक</string>
<string name="resource_usage_today">आज</string>
<string name="resource_usage_week">पिछले सात दिन</string>
<string name="resource_usage_tile_cellular">चलनशील आज</string>
@@ -3291,7 +3387,7 @@
<string name="namecoin_testing">सेवासंगणकों का परीक्षण…</string>
<string name="namecoin_test_success">संयोजित</string>
<string name="namecoin_test_results">परीक्षा परिणाम</string>
<string name="namecoin_diagnostics">निदानार्थ लक्षण</string>
<string name="namecoin_diagnostics">निदानतन्त्र</string>
<string name="namecoin_last_test">अन्तिम परीक्षा</string>
<string name="namecoin_device_info">यन्त्र जानकारी</string>
<string name="namecoin_tls_info">टीएलएस॰ जानकारी</string>
@@ -3803,6 +3899,10 @@
<string name="marmot_group_name_placeholder">समूह नाम प्रविष्ट करें</string>
<string name="marmot_group_description_placeholder">समूह विवरण प्रविष्ट करें (विकल्पात्मक)</string>
<string name="marmot_edit_info_footer">परिवर्तनों का स्थायी अभिलेखन समूह में किया जाएगा एमएलएस॰ द्वारा तथा सभी सदस्यों तक प्रसारित किया जाएगा।</string>
<string name="marmot_group_icon">समूह चिह्न</string>
<string name="marmot_add_photo">चित्र जोडें</string>
<string name="marmot_change_photo">चित्र परिवर्तन</string>
<string name="marmot_remove_photo">चित्र हटाएँ</string>
<string name="marmot_group_info_updated">समूह विवरण का अद्यतन किया गया</string>
<string name="marmot_failed_to_update">अद्यतन असफल : %1$s</string>
<string name="marmot_failed_to_create_group">समूह बनाने में असफल : %1$s</string>
+104 -4
View File
@@ -97,6 +97,7 @@
<string name="security_filters">Biztonsági szűrők</string>
<string name="log_out">Kijelentkezés</string>
<string name="show_more">Továbbiak megjelenítése</string>
<string name="show_less">Kevesebb megjelenítése</string>
<string name="lightning_invoice">Lightning-számla</string>
<string name="invoice_expired">Lejárt</string>
<string name="clink_lightning_offer">CLINK ajánlat</string>
@@ -298,6 +299,9 @@
<string name="loading_account">Fiók betöltése…</string>
<string name="concord_redeeming_invite">Meghívó beváltása…</string>
<string name="concord_invite_failed">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.</string>
<string name="concord_invite_failed_invalid">Ez a meghívási hivatkozás érvénytelen, vagy nem nyitható meg ezzel a fiókkal.</string>
<string name="concord_invite_failed_incompatible">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.</string>
<string name="concord_invite_failed_revoked">Ezt a meghívási hivatkozást visszavonták, és már nem használható. Kérjen egy újat.</string>
<string name="concord_home_title">Concord-csatornák</string>
<string name="concord_home_empty">Még nem csatlakozott egyetlen Concord-csatornához sem. Hozzon létre egyet, vagy nyisson meg egy meghívóhivatkozást.</string>
<string name="concord_channels_empty">Még nincsenek csatornák.</string>
@@ -330,6 +334,11 @@
<item quantity="one">%1$d tag</item>
<item quantity="other">%1$d tag</item>
</plurals>
<plurals name="concord_unread_messages">
<item quantity="one">%1$d új üzenet</item>
<item quantity="other">%1$d új üzenet</item>
</plurals>
<string name="concord_channel_no_messages">Még nincsenek üzenetek</string>
<string name="concord_create_action">Létrehozás</string>
<string name="concord_invite_action">Tagok meghívása</string>
<string name="concord_invite_title">Meghívóhivatkozás</string>
@@ -401,7 +410,7 @@
rövid élettartamú, ezért a csevegési üzenetek idővel eltűnnek</string>
<string name="public_chat">Nyilvános csevegés</string>
<string name="new_public_chat">Új nyilvános csevegés létrehozása</string>
<string name="marmot_group">MLS-csoport</string>
<string name="marmot_group">Marmot-csoport</string>
<string name="marmot_group_no_messages_yet">Még nincs üzenet</string>
<string name="public_chat_title">Nyilvános csevegés metaadatai</string>
<string name="public_chat_explainer">A nyilvános csevegések mindenki számára láthatóak a Nostr-on, és bárki
@@ -896,7 +905,7 @@
<string name="relay_auth_name_and_others">%1$s és mások</string>
<string name="relay_auth_reason_send_dm">Privát üzenet küldése neki:</string>
<string name="relay_auth_reason_notify_inbox">Értesítendő:</string>
<string name="relay_auth_reason_read_outbox">Bejegyzések letöltése tőle:</string>
<string name="relay_auth_reason_read_outbox">Profiladatok, bejegyzések és interakciók letöltése tőle:</string>
<string name="relay_auth_reason_post_venue">Közzététel ebben a szobában</string>
<string name="relay_auth_reason_read_venue">Szoba megnyitása</string>
<string name="relay_auth_reason_other">Átjátszó használata</string>
@@ -908,7 +917,7 @@
<string name="relay_auth_per_relay_overrides">Átjátszónkénti felülírások</string>
<string name="relay_auth_forget">Elfelejtés</string>
<string name="relay_auth_last_used">Utoljára használva: %1$s</string>
<string name="relay_auth_no_overrides">Itt még nincs semmi. A globális szabályzat minden átjátszóra vonatkozik.</string>
<string name="relay_auth_no_overrides">Itt még nincs semmi. A globális szabály minden átjátszóra vonatkozik.</string>
<string name="relay_auth_decision_allow">Engedélyezés</string>
<string name="relay_auth_decision_deny">Megtagadás</string>
<string name="nip82_repository_label">Forrás: %1$s</string>
@@ -1862,6 +1871,63 @@
<string name="messages_create_public_private_chat_description">Új nyilvános vagy privát csoport</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="new_conversation_title">Beszélgetés indítása</string>
<string name="new_conversation_best_for">Legalkalmasabb ehhez:</string>
<string name="new_conversation_pros">Előnyök</string>
<string name="new_conversation_cons">Kompromisszumok</string>
<string name="new_conversation_section_direct">Privát</string>
<string name="new_conversation_section_encrypted">Titkosított csoportok</string>
<string name="new_conversation_section_relay">Átjátszó-tudatos</string>
<string name="new_conversation_dm_title">Privát üzenet</string>
<string name="new_conversation_dm_tagline">Közvetlen üzenetek, végpontok közötti titkosítással</string>
<string name="new_conversation_dm_chip">1:1-5 szoba</string>
<string name="new_conversation_dm_best">Személyes és kiscsoportos beszélgetések olyan emberekkel, akiket ismer.</string>
<string name="new_conversation_dm_cta">Új üzenet</string>
<string name="new_conversation_dm_pro_1">1-1 fős és kiscsoportos</string>
<string name="new_conversation_dm_pro_2">Végpontok közötti titkosítás</string>
<string name="new_conversation_dm_con_1">Később nem adhatók hozzá felhasználók</string>
<string name="new_conversation_marmot_title">Marmot csoport</string>
<string name="new_conversation_marmot_tagline">Nagyobb privát csoportok</string>
<string name="new_conversation_marmot_chip">Eszközhöz kötött</string>
<string name="new_conversation_marmot_best">Nagyobb privát csoportok, amelyek kinőtték a kiscsoportos közvetlen üzeneteket.</string>
<string name="new_conversation_marmot_cta">Csoport létrehozása</string>
<string name="new_conversation_marmot_pro_1">Túlmutat a kis közvetlen üzeneteken</string>
<string name="new_conversation_marmot_pro_2">Titkosított csoportos csevegés</string>
<string name="new_conversation_marmot_con_1">Eszközhöz kötött - nem fog megjelenni, ha máshol jelentkezik be</string>
<string name="new_conversation_concord_title">Concord közösség</string>
<string name="new_conversation_concord_tagline">Csatornákra bontott közösségek</string>
<string name="new_conversation_concord_chip">Munkaterületek</string>
<string name="new_conversation_concord_best">Hatalmas közösségek, csatornákba vagy munkafolyamatokba szervezve.</string>
<string name="new_conversation_concord_cta">Közösség létrehozása</string>
<string name="new_conversation_concord_pro_1">Hatalmas közösségekre skálázva</string>
<string name="new_conversation_concord_pro_2">Csatornára / munkafolyamatra osztott</string>
<string name="new_conversation_concord_con_1">Több beállítást igényel</string>
<string name="new_conversation_public_chat_title">Nyilvános csevegés</string>
<string name="new_conversation_public_chat_tagline">Nyilvános üzenetek egy átjátszón</string>
<string name="new_conversation_public_chat_chip">Moderálatlan</string>
<string name="new_conversation_public_chat_best">Nyitott, nyilvános szobák, ahová bárki küldhet üzenetet.</string>
<string name="new_conversation_public_chat_cta">Nyilvános csevegés létrehozása</string>
<string name="new_conversation_public_chat_pro_1">Bárki csatlakozhat</string>
<string name="new_conversation_public_chat_pro_2">Egyszerű és felfedezhető</string>
<string name="new_conversation_public_chat_con_1">Csak nyilvános</string>
<string name="new_conversation_public_chat_con_2">Nincs moderáció</string>
<string name="new_conversation_relay_group_title">Átjátszó-csoport</string>
<string name="new_conversation_relay_group_tagline">Átjátszó által futtatott, nyilvános vagy privát</string>
<string name="new_conversation_relay_group_chip">Moderált</string>
<string name="new_conversation_relay_group_best">Az őket kiszolgáló átjátszó által moderált csoportok.</string>
<string name="new_conversation_relay_group_cta">Átjátszó-csoportok böngészése</string>
<string name="new_conversation_relay_group_pro_1">Átjátszó által moderált</string>
<string name="new_conversation_relay_group_pro_2">Nyilvános vagy privát</string>
<string name="new_conversation_relay_group_con_1">Ahhoz az egy átjátszóhoz kötött</string>
<string name="new_conversation_ephemeral_title">Eltűnő csevegés</string>
<string name="new_conversation_ephemeral_tagline">Bárki, aki éppen online van</string>
<string name="new_conversation_ephemeral_chip">Most élő</string>
<string name="new_conversation_ephemeral_best">Pillanatnyi csevegés bárkivel, aki éppen online van.</string>
<string name="new_conversation_ephemeral_cta">Csevegés indítása</string>
<string name="new_conversation_ephemeral_pro_1">Beszélgetés azokkal, akik most online vannak</string>
<string name="new_conversation_ephemeral_pro_2">Semmi sem mentődik el</string>
<string name="new_conversation_ephemeral_con_1">Nincs előzmény</string>
<string name="new_conversation_ephemeral_con_2">Átjátszóra korlátozott</string>
<string name="relay_groups_title">Átjátszócsoportok</string>
<string name="relay_group_view_inline">Beágyazott</string>
<string name="relay_group_view_grouped">Átjátszó szerint</string>
@@ -1906,6 +1972,10 @@
<string name="relay_group_menu_members">Tagok</string>
<string name="relay_group_menu_edit">Csoport szerkesztése</string>
<string name="relay_group_threads_title">Szálak</string>
<string name="relay_group_pin_message">Üzenet kitűzése</string>
<string name="relay_group_unpin_message">Üzenet kitűzésének megszüntetése</string>
<string name="relay_group_pinned_label">Kitűzött</string>
<string name="relay_group_pinned_content_description">Kitűzött üzenetek</string>
<string name="relay_group_open">Csoport megnyitása</string>
<string name="relay_group_channels_empty">Még nincsenek csoportok ezen az átjátszón.</string>
<string name="relay_group_channels_not_nip29">Ez az átjátszó nem jelzi a csoportok támogatását (NIP-29), így előfordulhat, hogy nem futtat egyet sem.</string>
@@ -2040,6 +2110,8 @@
<string name="no_blossom_apps_found_description">Nem található Blossom-alkalmazás. Telepítsen egy helyi Blossom-alkalmazást a fájl megtekintéséhez</string>
<string name="hidden_words">Rejtett szavak</string>
<string name="hide_new_word_label">Új szó vagy mondat elrejtése</string>
<string name="mute_hashtag">Kulcsszó némítása</string>
<string name="unmute_hashtag">Kulcsszó némításának megszüntetése</string>
<string name="settings_muted_threads_title">Némított szálak</string>
<string name="settings_muted_threads_empty">Nincsenek némított szálak</string>
<string name="settings_muted_threads_unknown">Ismeretlen szál · %1$s</string>
@@ -2564,10 +2636,25 @@
<string name="onchain_zap_pending">Függőben lévő megerősítés</string>
<string name="change_reaction">Gyors reakciók megváltoztatása</string>
<string name="bottom_bar_settings">Alsó navigációs sáv</string>
<string name="bottom_bar_settings_description">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.</string>
<string name="bottom_bar_settings_description">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.</string>
<string name="bottom_bar_settings_available">Elérhető</string>
<string name="bottom_bar_settings_pinned">Saját alsó sáv</string>
<string name="bottom_bar_settings_pinned_empty">Nincsenek kitűzött elemek. Az alsó sáv rejtve marad, amíg hozzá nem ad legalább egyet.</string>
<string name="bottom_bar_settings_reorder">Újrarendezés</string>
<string name="bottom_bar_settings_expand">Beállítások megjelenítése</string>
<string name="bottom_bar_settings_no_favorites">Még nincsenek kedvencek. Jelöljön meg csillaggal egy alkalmazást a böngészőben, hogy hozzáadja ide.</string>
<string name="bottom_bar_settings_no_groups">Még nincsenek csatlakozott csoportok.</string>
<string name="bottom_bar_settings_reorder_hint">Húzza a rendezéshez, koppintson az ✕ gombra az eltávolításhoz</string>
<string name="bottom_bar_settings_add">Hozzáadás</string>
<string name="bottom_bar_settings_added">Hozzáadva</string>
<string name="bottom_bar_settings_remove">Eltávolítás</string>
<string name="bottom_bar_settings_restore_default">Alapértelmezés visszaállítása</string>
<string name="bottom_bar_category_main">Fő</string>
<string name="bottom_bar_category_chats">Csevegések és csoportok</string>
<string name="bottom_bar_category_you">Ön</string>
<string name="bottom_bar_category_feeds">Hírfolyamok</string>
<string name="bottom_bar_category_apps">Alkalmazások és web</string>
<string name="bottom_bar_category_other">Egyéb</string>
<string name="home_tabs_settings">Kezdőlap lapjai</string>
<string name="home_tabs_settings_description">Válassza ki, mely lapok jelenjenek meg a kezdőlapon. Ha csak egy lap aktív, akkor a lapsáv rejtett.</string>
<string name="home_tab_everything">Minden</string>
@@ -2637,6 +2724,15 @@
<string name="remove_location">Helyszín eltávolítása</string>
<string name="add_content_warning">Tartalmi figyelmeztetés hozzáadása</string>
<string name="remove_content_warning">Tartalmi figyelmeztetés eltávolítása</string>
<string name="chat_system_created_channel">%1$s létrehozta a(z) %2$s nevű csatornát</string>
<string name="chat_system_created_channel_unnamed">%1$s létrehozta a csatornát</string>
<string name="chat_system_updated_channel">%1$s frissítette a csatorna profilját</string>
<string name="chat_delivery_details_title">Üzenet kézbesítése</string>
<string name="close">Bezárás</string>
<string name="chat_delivery_pending">Várakozás egy átjátszóra az üzenet elfogadásához</string>
<string name="chat_delivery_no_relay_info">Nincs átjátszó-információ ehhez az üzenethez</string>
<string name="chat_delivery_accepted">Legalább egy átjátszó elfogadta</string>
<string name="chat_delivery_delivered_all">Kézbesítve az összes címzett átjátszójára</string>
<string name="add_expiration_date">Lejárati dátum hozzáadása</string>
<string name="remove_expiration_date">Lejárati dátum törlése</string>
<string name="expiration_date_label">Lejárati dátum</string>
@@ -3804,6 +3900,10 @@
<string name="marmot_group_name_placeholder">Adja meg a csoport nevét</string>
<string name="marmot_group_description_placeholder">Adja meg a csoport leírását (nem kötelező)</string>
<string name="marmot_edit_info_footer">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é.</string>
<string name="marmot_group_icon">Csoport ikonja</string>
<string name="marmot_add_photo">Fotó hozzáadása</string>
<string name="marmot_change_photo">Fotó módosítása</string>
<string name="marmot_remove_photo">Fotó eltávolítása</string>
<string name="marmot_group_info_updated">Csoportadatok frissítve</string>
<string name="marmot_failed_to_update">Nem sikerült frissíteni: %1$s</string>
<string name="marmot_failed_to_create_group">Nem sikerült létrehozni a csoportot: %1$s</string>
@@ -103,6 +103,7 @@
<string name="security_filters">Filtry bezpieczeństwa</string>
<string name="log_out">Wyloguj się</string>
<string name="show_more">Pokaż Więcej</string>
<string name="show_less">Pokaż mniej</string>
<string name="lightning_invoice">Faktura LN</string>
<string name="invoice_expired">Wygasła</string>
<string name="clink_lightning_offer">Oferta CLINK</string>
@@ -2778,6 +2779,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="remove_location">Usuń lokalizację</string>
<string name="add_content_warning">Dodaj ostrzeżenie o treści</string>
<string name="remove_content_warning">Usuń ostrzeżenie o treści</string>
<string name="chat_system_created_channel">%1$s utworzył kanał %2$s</string>
<string name="chat_system_created_channel_unnamed">%1$s utworzył kanał</string>
<string name="chat_system_updated_channel">%1$s zaktualizował profil kanału</string>
<string name="chat_delivery_details_title">Dostarczenie wiadomości</string>
<string name="close">Zamknij</string>
<string name="chat_delivery_pending">Oczekiwanie na akceptację tej wiadomości przez transmiter</string>
<string name="chat_delivery_no_relay_info">Brak informacji o transmiterze tej wiadomości</string>
<string name="chat_delivery_accepted">Akceptowane przez co najmniej jeden transmiter</string>
<string name="chat_delivery_delivered_all">Dostarczono do transmiterów wszystkich odbiorców</string>
<string name="add_expiration_date">Dodaj datę wygaśnięcia</string>
<string name="remove_expiration_date">Usuń datę wygaśnięcia</string>
<string name="expiration_date_label">Data wygaśnięcia</string>
@@ -3114,6 +3124,83 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="resource_usage_week">Ostatnie 7 dni</string>
<string name="resource_usage_tile_cellular">Komórka dzisiaj</string>
<string name="resource_usage_tile_wifi">Wi-Fi dzisiaj</string>
<string name="resource_usage_tile_relay">Czas transmitera dzisiaj</string>
<string name="resource_usage_tile_in_app">W aplikacji dzisiaj</string>
<string name="resource_usage_trend_section">Dane dziennie</string>
<string name="resource_usage_legend_cellular">Internet mobilny (MB)</string>
<string name="resource_usage_legend_wifi">Wi-Fi (MB)</string>
<string name="resource_usage_activity_section">Aktywność (7 dni)</string>
<string name="resource_usage_cellular_bg">Dane mobilny (w tle)</string>
<string name="resource_usage_cellular_fg">Dane komórkowe (w aplikacji)</string>
<string name="resource_usage_wifi">Dane Wi-Fi</string>
<string name="resource_usage_relay_time">Czas połączenia z transmiterem</string>
<string name="resource_usage_relay_time_bg_mobile">\u2026 w tle na komórce</string>
<string name="resource_usage_wakelock">Przetwarzanie powiadomień (procesor w stanie czuwania)</string>
<string name="resource_usage_worker_runs">Uruchomione zadania w tle</string>
<string name="resource_usage_reconnects">Ponowne połączenia transmiterów (nieudane)</string>
<string name="resource_usage_http_requests">Żądania sieci Web</string>
<string name="resource_usage_radio_bursts">Wybudzanie radiowe podczas pobierania (szacunkowo)</string>
<string name="resource_usage_http_active">Aktywny czas transferu</string>
<string name="resource_usage_media_play">Czas odtwarzania multimediów</string>
<string name="resource_usage_verifies">Podpisy zweryfikowane</string>
<string name="resource_usage_cpu">Czas wykorzystania procesora</string>
<string name="resource_usage_fg_time">Czas w aplikacji</string>
<string name="resource_usage_app_starts">Rozpoczęcie działania aplikacji</string>
<string name="resource_usage_decrypts">Wiadomości odszyfrowane</string>
<string name="resource_usage_remote_signs">Zdalne podpisy</string>
<string name="resource_usage_pow">Wydobycie Proof-of work</string>
<string name="resource_usage_tor">Czas działania sieci Tor</string>
<string name="resource_usage_calls">Czas rozmów</string>
<string name="resource_usage_nests">Czas w pokojach audio</string>
<string name="resource_usage_location">Wykrywanie lokalizacji</string>
<string name="resource_usage_battery_drain">Zużycie baterii (w aplikacji/tle)</string>
<string name="resource_usage_alwayson_section">Usługa powiadomień w tle</string>
<string name="resource_usage_alwayson_explanation">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ę.</string>
<string name="resource_usage_alwayson_uptime">Czas pracy</string>
<string name="resource_usage_alwayson_bg_relay">Połączenia transmiterów utrzymywane po zamknięciu</string>
<string name="resource_usage_alwayson_battery">W międzyczasie rozładowała się bateria (całe urządzenie)</string>
<string name="resource_usage_alwayson_settings_button">Zmień ustawienia powiadomień</string>
<string name="resource_usage_tor_settings_button">Ustawienia prywatności</string>
<string name="resource_usage_tile_battery_rate">Bateria / godz. w aplikacji</string>
<string name="resource_usage_tile_data_rate">Dane / godz. w aplikacji</string>
<string name="resource_usage_tile_avg_relays">Średnia liczba połączeń z transmiterem</string>
<string name="resource_usage_by_subsystem_cellular">Dane komórkowe wg funkcji (7 dni)</string>
<string name="resource_usage_cell_of_total">%1$s · %2$s łącznie</string>
<string name="resource_usage_screen_time_section">Czas wg ekranu (7 dni)</string>
<string name="resource_usage_tor_section">Wbudowany Tor</string>
<string name="resource_usage_tor_explanation">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ć.</string>
<string name="resource_usage_by_subsystem">Dane wg funkcji (7 dni)</string>
<string name="resource_usage_subsystem_relay">Synchronizacja Transmiterów</string>
<string name="resource_usage_subsystem_image">Zdjęcia</string>
<string name="resource_usage_subsystem_video">Audio &amp; wideo</string>
<string name="resource_usage_subsystem_uploads">Wgrane</string>
<string name="resource_usage_subsystem_money">Portfel &amp; zaps</string>
<string name="resource_usage_subsystem_nip05">Weryfikacja adresu</string>
<string name="resource_usage_subsystem_preview">Podgląd linku</string>
<string name="resource_usage_subsystem_push">Rejestracja Push</string>
<string name="resource_usage_subsystem_other">Inne</string>
<string name="resource_usage_empty">Nie odnotowano jeszcze żadnego użycia.</string>
<string name="resource_usage_memory_section">Pamięć teraz</string>
<string name="resource_usage_memory_heap">Pamięć aplikacji (heap)</string>
<string name="resource_usage_memory_native">Pamięć rodzima</string>
<string name="resource_usage_memory_image_cache">Pamięć podręczna (RAM) obrazu</string>
<string name="resource_usage_memory_image_disk">Image cache (disk)</string>
<string name="resource_usage_memory_notes">Notatki w pamięci</string>
<string name="resource_usage_memory_users">Profile w pamięci</string>
<string name="resource_usage_memory_addressables">Adresowalne wydarzenia w pamięci</string>
<string name="resource_usage_memory_chatrooms">Listy czatów w pamięci</string>
<string name="resource_usage_memory_device_class">Klasa pamięci urządzenia</string>
<string name="resource_usage_send_section">Udostępnij deweloperom</string>
<string name="resource_usage_send_explanation">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.</string>
<string name="resource_usage_send_button">Wyślij raport za pośrednictwem DM</string>
<string name="resource_usage_alert_title">Wykryto wysokie użycie zasobów</string>
<string name="resource_usage_alert_message">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.</string>
<string name="resource_usage_reason_bg_data">%1$s danych komórkowych w tle w ciągu jednego dnia</string>
<string name="resource_usage_reason_conn_time">%1$s połączeń z transmiterem w ciągu jednego dnia, gdy aplikacja działała w tle przy połączeniu komórkowym</string>
<string name="resource_usage_reason_wakelock">procesor pozostawał w stanie czuwania przez %1$s w celu przetwarzania powiadomień w ciągu jednego dnia</string>
<string name="resource_usage_alert_send">Przejrzyj &amp; wyślij</string>
<string name="resource_usage_alert_not_now">Nie teraz</string>
<string name="resource_usage_alert_opt_out">Nie pytaj ponownie</string>
<string name="this_message_will_disappear_in">Ta wiadomość zniknie za %1$s</string>
<string name="select_signer">Wybierz Sygnatariusza</string>
<string name="in_the_list">Już jest na liście</string>
@@ -3478,6 +3565,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="playback_actions_dialog_title">Odtwarzanie</string>
<string name="video_quality_auto">Automatycznie</string>
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
<string name="edit_nickname">Edytuj pseudonim</string>
<string name="nickname_private">Widoczne tylko dla Ciebie</string>
<!-- LAN cast (Chromecast) feature -->
<string name="cast_to_device">Zrzuć na urządzenie</string>
<string name="cast_stop_casting">Przestań zrzucać</string>
@@ -3706,6 +3795,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="compose_signature_setting_description">Dodawane na końcu wiadomości podczas tworzenia nowego wpisu, odpowiedzi, cytatu lub artykułu. Aby wyłączyć tę funkcję, pozostaw to pole puste.</string>
<string name="compose_signature_setting_hint">Twój podpis</string>
<string name="pow_settings_title">Dowód wykonania</string>
<string name="pow_info_description">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.</string>
<string name="pow_difficulty_title">Stopień trudności</string>
<string name="pow_difficulty_explainer">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.</string>
<string name="pow_difficulty_off">Wył.</string>
@@ -3899,6 +3989,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="marmot_group_name_placeholder">Wpisz nazwę grupy</string>
<string name="marmot_group_description_placeholder">Dodaj opis grupy (opcjonalnie)</string>
<string name="marmot_edit_info_footer">Zmiany zostaną zatwierdzone w grupie za pośrednictwem MLS i przekazane wszystkim członkom.</string>
<string name="marmot_group_icon">Ikona grupy</string>
<string name="marmot_add_photo">Dodaj zdjęcie</string>
<string name="marmot_change_photo">Zmień zdjęcie</string>
<string name="marmot_remove_photo">Usuń zdjęcie</string>
<string name="marmot_group_info_updated">Informacje o grupie zaktualizowane</string>
<string name="marmot_failed_to_update">Nie udało się zaktualizować: %1$s</string>
<string name="marmot_failed_to_create_group">Nie udało się utworzyć grupy: %1$s</string>
+107 -2
View File
@@ -94,6 +94,7 @@
<string name="security_filters">安全过滤器</string>
<string name="log_out">退出</string>
<string name="show_more">显示更多</string>
<string name="show_less">显示较少</string>
<string name="lightning_invoice">闪电发票</string>
<string name="invoice_expired">已过期</string>
<string name="clink_lightning_offer">CLINK 报价</string>
@@ -294,6 +295,9 @@
<string name="loading_account">正在载入帐户</string>
<string name="concord_redeeming_invite">兑换邀请中…</string>
<string name="concord_invite_failed">无法获取此邀请。链接可能已过期或其中继不可访问。</string>
<string name="concord_invite_failed_invalid">此邀请链接无效或无法用此账户打开。</string>
<string name="concord_invite_failed_incompatible">此邀请是用新版本的应用创建,尚无法在此处打开。 请求已更新的链接,或在更新后重试。</string>
<string name="concord_invite_failed_revoked">此邀请链接已被撤销,不能再使用。请求新链接。</string>
<string name="concord_home_title">Concord 频道</string>
<string name="concord_home_empty">您尚未加入任何Concord频道。创建一个频道,或者打开一个邀请链接。</string>
<string name="concord_channels_empty">尚无频道。</string>
@@ -324,6 +328,10 @@
<plurals name="concord_member_count">
<item quantity="other">%1$d 位成员</item>
</plurals>
<plurals name="concord_unread_messages">
<item quantity="other">%1$d 条新消息</item>
</plurals>
<string name="concord_channel_no_messages">尚无消息</string>
<string name="concord_create_action">创建</string>
<string name="concord_invite_action">邀请其他人</string>
<string name="concord_invite_title">邀请链接</string>
@@ -394,7 +402,7 @@
因此聊天信息会随着时间的推移而消失。</string>
<string name="public_chat">公共聊天</string>
<string name="new_public_chat">创建新的公开聊天</string>
<string name="marmot_group">Marmot 群</string>
<string name="marmot_group">Marmot 群组</string>
<string name="marmot_group_no_messages_yet">尚无消息</string>
<string name="public_chat_title">公开聊天元数据</string>
<string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以:
@@ -1840,6 +1848,63 @@
<string name="messages_create_public_private_chat_description">新建公开或私人群组</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="new_conversation_title">开始对话</string>
<string name="new_conversation_best_for">最适合</string>
<string name="new_conversation_pros">好</string>
<string name="new_conversation_cons">折衷办法</string>
<string name="new_conversation_section_direct">私密</string>
<string name="new_conversation_section_encrypted">加密群组</string>
<string name="new_conversation_section_relay">支持中继</string>
<string name="new_conversation_dm_title">私信</string>
<string name="new_conversation_dm_tagline">私信,端到端加密</string>
<string name="new_conversation_dm_chip">1:1–5 个房间</string>
<string name="new_conversation_dm_best">和你认识的人的一对一及小型群聊。</string>
<string name="new_conversation_dm_cta">新消息</string>
<string name="new_conversation_dm_pro_1">1-1 &amp; 小型群组</string>
<string name="new_conversation_dm_pro_2">端到端加密</string>
<string name="new_conversation_dm_con_1">稍后无法添加用户</string>
<string name="new_conversation_marmot_title">Marmot 群</string>
<string name="new_conversation_marmot_tagline">较大的私密群</string>
<string name="new_conversation_marmot_chip">绑定设备</string>
<string name="new_conversation_marmot_best">规模较大的私密群组,不再适合小型群组私信。</string>
<string name="new_conversation_marmot_cta">创建群组</string>
<string name="new_conversation_marmot_pro_1">发送大量私信</string>
<string name="new_conversation_marmot_pro_2">加密的群组聊天</string>
<string name="new_conversation_marmot_con_1">绑定设备-如果您在其他地方登录则不会显示</string>
<string name="new_conversation_concord_title">Concord 社区</string>
<string name="new_conversation_concord_tagline">按频道拆分社区</string>
<string name="new_conversation_concord_chip">工作区域</string>
<string name="new_conversation_concord_best">根据频道或工作流组织的大规模社区。</string>
<string name="new_conversation_concord_cta">创建社区</string>
<string name="new_conversation_concord_pro_1">扩大到超大社区</string>
<string name="new_conversation_concord_pro_2">按照频道/工作流拆分</string>
<string name="new_conversation_concord_con_1">更多待设置</string>
<string name="new_conversation_public_chat_title">公开聊天</string>
<string name="new_conversation_public_chat_tagline">中继上的公开消息</string>
<string name="new_conversation_public_chat_chip">无人管理</string>
<string name="new_conversation_public_chat_best">任何人都可以发帖的开放公共房间。</string>
<string name="new_conversation_public_chat_cta">创建公开聊天</string>
<string name="new_conversation_public_chat_pro_1">任何人都可以加入</string>
<string name="new_conversation_public_chat_pro_2">简单 &amp; 可发现</string>
<string name="new_conversation_public_chat_con_1">仅公开</string>
<string name="new_conversation_public_chat_con_2">无审核</string>
<string name="new_conversation_relay_group_title">中继群组</string>
<string name="new_conversation_relay_group_tagline">中继运行,公开或私密</string>
<string name="new_conversation_relay_group_chip">有审核</string>
<string name="new_conversation_relay_group_best">群组由托管它们的中继管理。</string>
<string name="new_conversation_relay_group_cta">浏览中继群组</string>
<string name="new_conversation_relay_group_pro_1">由中继管理</string>
<string name="new_conversation_relay_group_pro_2">公开或私密</string>
<string name="new_conversation_relay_group_con_1">绑定单一中继</string>
<string name="new_conversation_ephemeral_title">临时聊天</string>
<string name="new_conversation_ephemeral_tagline">不论谁在线,立马聊天</string>
<string name="new_conversation_ephemeral_chip">当前在线</string>
<string name="new_conversation_ephemeral_best">与当前在线的任何人进行即时聊天。</string>
<string name="new_conversation_ephemeral_cta">开始聊天</string>
<string name="new_conversation_ephemeral_pro_1">与当前在线的人交谈</string>
<string name="new_conversation_ephemeral_pro_2">不保存任何内容</string>
<string name="new_conversation_ephemeral_con_1">没有历史记录</string>
<string name="new_conversation_ephemeral_con_2">限于中继范围</string>
<string name="relay_groups_title">中继组</string>
<string name="relay_group_view_inline">行内</string>
<string name="relay_group_view_grouped">根据中继</string>
@@ -1872,6 +1937,7 @@
<string name="relay_group_members_title">成员</string>
<string name="relay_group_make_admin">设为管理员</string>
<string name="relay_group_make_moderator">设为协管</string>
<string name="relay_group_assign_role">分配角色: %1$s</string>
<string name="relay_group_demote_member">删除职位</string>
<string name="relay_group_remove_user">从群组中删除</string>
<string name="relay_group_remove_user_confirm">从此群中移除 %1$s ?在重新添加或重新邀请之前这些人将失去访问权限。</string>
@@ -1884,6 +1950,10 @@
<string name="relay_group_menu_members">成员</string>
<string name="relay_group_menu_edit">编辑群</string>
<string name="relay_group_threads_title">主题帖</string>
<string name="relay_group_pin_message">固定消息</string>
<string name="relay_group_unpin_message">取消固定消息</string>
<string name="relay_group_pinned_label">已固定</string>
<string name="relay_group_pinned_content_description">固定的消息</string>
<string name="relay_group_open">打开群组</string>
<string name="relay_group_channels_empty">此中继上尚没有群。</string>
<string name="relay_group_channels_not_nip29">这个中继不广播群组支持 (NIP-29),所以它可能不托管任何群组。</string>
@@ -1903,6 +1973,15 @@
<string name="relay_group_field_topics_hint">比特币,nostr,艺术品</string>
<string name="relay_group_field_geohash">位置 (gehash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_section_structure">结构</string>
<string name="relay_group_parent_desc">把这个群置于父级下方以构建一个层次结构。</string>
<string name="relay_group_parent_label">父群</string>
<string name="relay_group_parent_none">顶层群</string>
<string name="relay_group_parent_pick_title">选择父群</string>
<string name="relay_group_parent_search">搜索群</string>
<string name="relay_group_parent_top_level_option">没有父级(顶层)</string>
<string name="relay_group_parent_none_desc">此群位于顶层</string>
<string name="relay_group_parent_empty">此中继上尚没有其他群</string>
<string name="relay_group_section_permissions">权限</string>
<string name="relay_group_flag_private">私密</string>
<string name="relay_group_flag_private_desc">只有成员可以阅读消息。</string>
@@ -2014,6 +2093,8 @@
<string name="no_blossom_apps_found_description">找不到Blossom应用。请安装本地Blossom应用查看此文件</string>
<string name="hidden_words">隐藏单词</string>
<string name="hide_new_word_label">隐藏新单词或句子</string>
<string name="mute_hashtag">静音话题标签</string>
<string name="unmute_hashtag">取消静音话题标签</string>
<string name="settings_muted_threads_title">静音的主题帖</string>
<string name="settings_muted_threads_empty">无静音的主题帖</string>
<string name="settings_muted_threads_unknown">未知主题帖 · %1$s</string>
@@ -2529,8 +2610,23 @@
<string name="bottom_bar_settings">底部导航栏</string>
<string name="bottom_bar_settings_description">拖动可重新排序。切换可从底部栏中添加或删除一个项目。无项目则底部栏隐藏。</string>
<string name="bottom_bar_settings_available">可用</string>
<string name="bottom_bar_settings_pinned">您的底部栏</string>
<string name="bottom_bar_settings_pinned_empty">没有固定的项目。在您添加至少一个项目前不显示底部栏。</string>
<string name="bottom_bar_settings_reorder">调整顺序</string>
<string name="bottom_bar_settings_expand">显示选项</string>
<string name="bottom_bar_settings_no_favorites">尚无收藏内容。在浏览器中给应用添加星标以在此添加。</string>
<string name="bottom_bar_settings_no_groups">尚未加入任何群组。</string>
<string name="bottom_bar_settings_reorder_hint">拖动以重新排序 · 点击✕删除</string>
<string name="bottom_bar_settings_add">添加</string>
<string name="bottom_bar_settings_added">已添加</string>
<string name="bottom_bar_settings_remove">删除</string>
<string name="bottom_bar_settings_restore_default">恢复默认</string>
<string name="bottom_bar_category_main">主要</string>
<string name="bottom_bar_category_chats">聊天 &amp; 群组</string>
<string name="bottom_bar_category_you">您</string>
<string name="bottom_bar_category_feeds">订阅源</string>
<string name="bottom_bar_category_apps">Apps &amp; Web</string>
<string name="bottom_bar_category_other">其它</string>
<string name="home_tabs_settings">主页标签</string>
<string name="home_tabs_settings_description">选择主屏幕上出现的标签页。当只有一个标签处于活动状态时,标签栏将被隐藏。</string>
<string name="home_tab_everything">全部</string>
@@ -2600,6 +2696,15 @@
<string name="remove_location">移除地点</string>
<string name="add_content_warning">添加内容警告</string>
<string name="remove_content_warning">移除内容警告</string>
<string name="chat_system_created_channel">%1$s 创建了频道 %2$s</string>
<string name="chat_system_created_channel_unnamed">%1$s 创建了频道</string>
<string name="chat_system_updated_channel">%1$s 更新了频道配置文件</string>
<string name="chat_delivery_details_title">消息传递</string>
<string name="close">关闭</string>
<string name="chat_delivery_pending">正在等待中继接受此消息</string>
<string name="chat_delivery_no_relay_info">没有此消息的中继信息</string>
<string name="chat_delivery_accepted">被至少一个中继接受</string>
<string name="chat_delivery_delivered_all">已传送到所有收信人的中继</string>
<string name="add_expiration_date">添加过期日期</string>
<string name="remove_expiration_date">删除过期日期</string>
<string name="expiration_date_label">过期日期</string>
@@ -2945,7 +3050,7 @@
<string name="resource_usage_worker_runs">已运行后台任务</string>
<string name="resource_usage_reconnects">中继重连(失败的)</string>
<string name="resource_usage_http_requests">Web 请求</string>
<string name="resource_usage_radio_bursts">下载的广播唤醒数(预估)</string>
<string name="resource_usage_radio_bursts">为下载进行的广播唤醒数(预估)</string>
<string name="resource_usage_http_active">有效传输时间</string>
<string name="resource_usage_media_play">媒体播放时间</string>
<string name="resource_usage_verifies">签名已验证</string>
+33
View File
@@ -309,6 +309,9 @@
<string name="loading_account">Loading account</string>
<string name="concord_redeeming_invite">Redeeming invite…</string>
<string name="concord_invite_failed">Could not fetch this invite. The link may be expired or its relays unreachable.</string>
<string name="concord_invite_failed_invalid">This invite link is invalid or can\'t be opened with this account.</string>
<string name="concord_invite_failed_incompatible">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.</string>
<string name="concord_invite_failed_revoked">This invite link has been revoked and can no longer be used. Ask for a new one.</string>
<string name="concord_home_title">Concord Channels</string>
<string name="concord_home_empty">You haven\'t joined any Concord Channels yet. Create one, or open an invite link.</string>
<string name="concord_channels_empty">No channels yet.</string>
@@ -341,6 +344,11 @@
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<plurals name="concord_unread_messages">
<item quantity="one">%1$d new message</item>
<item quantity="other">%1$d new messages</item>
</plurals>
<string name="concord_channel_no_messages">No messages yet</string>
<string name="concord_create_action">Create</string>
<string name="concord_invite_action">Invite people</string>
<string name="concord_invite_title">Invite link</string>
@@ -1695,6 +1703,15 @@
<string name="always_on_notif_setting_title">Always-on notification service</string>
<string name="always_on_notif_setting_description">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.</string>
<string name="always_on_notif_tile_label">Notification service</string>
<string name="always_on_notif_tile_subtitle_on">On</string>
<string name="always_on_notif_tile_subtitle_off">Off</string>
<string name="notification_service_master_title">Background notification service</string>
<string name="notification_service_master_description">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.</string>
<string name="notification_service_accounts_title">Accounts active in the background</string>
<string name="notification_service_participation_title">Keep this account active in the background</string>
<string name="split_notifications_setting_title">Split notifications by Follows</string>
<string name="split_notifications_setting_description">Show two notification tabs — Following (people you follow) and Everyone. The unread indicator glows only for activity from people you follow.</string>
<string name="show_messages_in_notifications_setting_title">Show Messages</string>
@@ -2150,6 +2167,7 @@
<string name="relay_group_members_title">Members</string>
<string name="relay_group_make_admin">Make admin</string>
<string name="relay_group_make_moderator">Make moderator</string>
<string name="relay_group_assign_role">Assign role: %1$s</string>
<string name="relay_group_demote_member">Remove role</string>
<string name="relay_group_remove_user">Remove from group</string>
<string name="relay_group_remove_user_confirm">Remove %1$s from this group? They will lose access until re-added or re-invited.</string>
@@ -2162,6 +2180,10 @@
<string name="relay_group_menu_members">Members</string>
<string name="relay_group_menu_edit">Edit group</string>
<string name="relay_group_threads_title">Threads</string>
<string name="relay_group_pin_message">Pin message</string>
<string name="relay_group_unpin_message">Unpin message</string>
<string name="relay_group_pinned_label">Pinned</string>
<string name="relay_group_pinned_content_description">Pinned messages</string>
<string name="relay_group_open">Open group</string>
<string name="relay_group_channels_empty">No groups on this relay yet.</string>
<string name="relay_group_channels_not_nip29">This relay does not advertise support for groups (NIP-29), so it may not host any.</string>
@@ -2181,6 +2203,15 @@
<string name="relay_group_field_topics_hint">bitcoin, nostr, art</string>
<string name="relay_group_field_geohash">Location (geohash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_section_structure">Structure</string>
<string name="relay_group_parent_desc">Nest this group under a parent to build a hierarchy.</string>
<string name="relay_group_parent_label">Parent group</string>
<string name="relay_group_parent_none">Top-level group</string>
<string name="relay_group_parent_pick_title">Choose a parent group</string>
<string name="relay_group_parent_search">Search groups</string>
<string name="relay_group_parent_top_level_option">No parent (top-level)</string>
<string name="relay_group_parent_none_desc">This group sits at the top level</string>
<string name="relay_group_parent_empty">No other groups on this relay yet</string>
<string name="relay_group_section_permissions">Permissions</string>
<string name="relay_group_flag_private">Private</string>
<string name="relay_group_flag_private_desc">Only members can read messages.</string>
@@ -2320,6 +2351,8 @@
<string name="hidden_words">Hidden Words</string>
<string name="hide_new_word_label">Hide new word or sentence</string>
<string name="mute_hashtag">Mute hashtag</string>
<string name="unmute_hashtag">Unmute hashtag</string>
<string name="settings_muted_threads_title">Muted threads</string>
<string name="settings_muted_threads_empty">No muted threads</string>
<string name="settings_muted_threads_unknown">Unknown thread · %1$s</string>
@@ -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 {
@@ -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 {
@@ -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<Event>,
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)
@@ -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)
@@ -61,11 +61,15 @@ data class LiveHiddenUsers(
val spammers: Set<String> = emptySet(),
val hiddenWords: Set<String> = emptySet(),
val mutedThreads: Set<String> = emptySet(),
// Lowercased hashtags (without the leading '#') the user has muted via NIP-51 `"t"` entries.
val hiddenHashtags: Set<String> = 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())
}
/**
@@ -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
@@ -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<HexKey> = 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<RoleTag> = 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<String> = 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<String> =
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
@@ -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<NoteState> = blossomListNote.flow().metadata.stateFlow
fun getBlossomServersList(): BlossomServersEvent? = blossomListNote.event as? BlossomServersEvent
fun normalizeServers(note: Note): List<String> = (note.event as? BlossomServersEvent)?.servers() ?: emptyList()
val flow: StateFlow<List<String>> =
getBlossomServersListFlow()
.map { normalizeServers(it.note) }
.onStart { emit(normalizeServers(blossomListNote)) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
suspend fun saveBlossomServersList(servers: List<String>): 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,
)
}
}
}
@@ -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<PersistentMap<NormalizedRelayUrl, PendingAuthApproval>>(persistentMapOf())
/** The live set of pending tier-2 challenges the AUTH banner renders. */
val pending: StateFlow<PersistentMap<NormalizedRelayUrl, PendingAuthApproval>> = _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<NormalizedRelayUrl>) {
// 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) }
}
}
@@ -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<NormalizedRelayUrl>,
private val localLookup: (HexKey) -> List<NormalizedRelayUrl>?,
private val outboxLookup: (HexKey) -> List<NormalizedRelayUrl>? = { 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<NormalizedRelayUrl> {
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)
@@ -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")
}
}
@@ -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<AuthApprovalScope> {
val deferred = CompletableDeferred<AuthApprovalScope>()
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())
}
}
@@ -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)
}
}
@@ -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()
@@ -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<NormalizedRelayUrl>) =
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<NormalizedRelayUrl>) =
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<Pair<Int, NormalizedRelayUrl>, List<Event>>()
val queriedRelays = mutableSetOf<NormalizedRelayUrl>()
fun scriptEvent(
kind: Int,
relay: NormalizedRelayUrl,
events: List<Event>,
) {
script[kind to relay] = events
}
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
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)
}
}
@@ -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<String>
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
@@ -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<Filter>?,
) {
// 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<Filter>?,
) {
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<String>) -> 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<String>()) })
// 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))
@@ -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<PersistentMap<NormalizedRelayUrl, PendingAuthApproval>>(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<PersistentMap<NormalizedRelayUrl, PendingAuthApproval>> = _pendingApprovals.asStateFlow()
val pendingApprovals: StateFlow<PersistentMap<NormalizedRelayUrl, PendingAuthApproval>> = 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<NormalizedRelayUrl>) {
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
}
@@ -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()
}
}
@@ -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
@@ -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"
@@ -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<HexKey>()
// 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<HexKey>) {
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,
@@ -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<Long?>(null)
val lastEventAt: StateFlow<Long?> = _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<HexKey>()
private val pendingOutbox = ConcurrentHashMap.newKeySet<HexKey>()
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()
}
/**
@@ -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<String>()) })
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<UploadResult>): List<IMetaTag> =
@Composable
private fun ServerSelector(
servers: List<String>,
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) {
@@ -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<StateFlow<List<String>>?> { null }
@@ -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<List<String>>?,
) {
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) {
@@ -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<String>) {
(account as? DesktopIAccount)?.prewarmDmInboxRelays(pubkeys)
}
private suspend fun refreshRooms() {
val chatroomList = account.chatroomList
val known = mutableListOf<ConversationItem>()
@@ -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(
@@ -186,6 +186,7 @@ fun DesktopMessagesScreen(
showNewDmDialog = false
},
onDismiss = { showNewDmDialog = false },
onPrewarmDmRelays = { (account as? DesktopIAccount)?.prewarmDmInboxRelays(it) },
)
}
@@ -75,6 +75,7 @@ fun NewDmDialog(
localCache: DesktopLocalCache,
onUserSelected: (ChatroomKey) -> Unit,
onDismiss: () -> Unit,
onPrewarmDmRelays: (Collection<String>) -> 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 }
@@ -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<DesktopScreen>()
@@ -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)
}
}
},
)
}
@@ -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,
@@ -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)),
)
}
}
@@ -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<String>,
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() })
}
}
@@ -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<NormalizedRelayUrl> = 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())
}
}
+6 -6
View File
@@ -122,18 +122,18 @@
"Vietnamese"
]
},
{
"user": "rajs19420616",
"languages": [
"Hindi"
]
},
{
"user": "maxblake2015",
"languages": [
"Polish"
]
},
{
"user": "rajs19420616",
"languages": [
"Hindi"
]
},
{
"user": "summoner001",
"languages": [
+3 -1
View File
@@ -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
kotlin.mpp.enableCInteropCommonization=true
# Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true
+113
View File
@@ -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<PersistentMap<url, LimitsMessage>>`. 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.
@@ -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<Event>,
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
@@ -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<Array<String>>,
): 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
}
}
}
@@ -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<Int>?,
@SerialName("blocked_event_kinds") val blockedEventKinds: Set<Int>?,
@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<Array<String>>?,
)
@@ -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<Array<String>>,
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<Array<String>>,
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<Array<String>>,
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
}
}
}
}
}
@@ -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<Int>? = (this[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.intOrNull }
private fun JsonObject.tagList(key: String): List<List<String>>? =
(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"),
)
}
@@ -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<Message> {
add(JsonPrimitive(value.subId))
}
is LimitsMessage -> {
// LIMITS wire format: ["LIMITS", { <limit_properties> }]
add(LimitsKSerializer.serializeToElement(value))
}
is NegMsgMessage -> {
add(JsonPrimitive(value.subId))
add(JsonPrimitive(value.message))
@@ -160,6 +167,12 @@ object MessageKSerializer : KSerializer<Message> {
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,
@@ -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}" }
}
}
@@ -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<PersistentMap<NormalizedRelayUrl, LimitsMessage>>(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<PersistentMap<NormalizedRelayUrl, LimitsMessage>> = _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<NormalizedRelayUrl, LimitsMessage> = _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)
}
}
@@ -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", { <limit_properties> }]`.
*
* 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<Int>? = null,
/** Denylist of event kinds the relay rejects for publishing. */
val blockedEventKinds: List<Int>? = 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<List<String>>? = null,
) : Message {
override fun label() = LABEL
companion object {
const val LABEL = "LIMITS"
}
}
@@ -53,6 +53,7 @@ data class Nip11RelayInformation(
val fees: RelayInformationFees? = null,
val nip50: List<String>? = null,
val supported_grasps: List<String>? = 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<Nip11RelayInformation>(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(
@@ -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
@@ -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=<code>
* ```
* 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=<code>` 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=<code>`. Fall back to a bare
// `invite=<code>` 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() }
}
}
@@ -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<GroupTreeNode>,
)
/**
* 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<GroupMetadataEvent>): List<GroupTreeNode> {
val byId = LinkedHashMap<String, GroupMetadataEvent>()
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<String, MutableList<GroupMetadataEvent>>()
byId.values.forEach { event ->
val parent = event.parent()
if (parent != null && parent in byId) {
childrenOf.getOrPut(parent) { mutableListOf() }.add(event)
}
}
val placed = HashSet<String>()
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
}
}
@@ -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<String> = 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<Int>? = null,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
parent: String? = null,
children: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GroupMetadataEvent>.() -> 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()
}
}
@@ -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<Array<String>>,
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<HexKey> = tags.mapValueTagged("e") { it }
companion object {
const val KIND = 39005
fun build(
groupId: String,
pinnedEventIds: List<HexKey>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GroupPinnedEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
dTag(groupId)
pinnedEventIds.forEach { add(arrayOf("e", it)) }
initializer()
}
}
}

Some files were not shown because too many files have changed in this diff Show More