mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+34
@@ -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) {
|
||||
|
||||
+71
-29
@@ -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() {
|
||||
|
||||
+11
-5
@@ -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
|
||||
}
|
||||
|
||||
+141
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
|
||||
+3
-6
@@ -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)
|
||||
|
||||
+14
@@ -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)
|
||||
|
||||
+29
@@ -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(),
|
||||
|
||||
+46
@@ -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
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
+38
@@ -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
|
||||
|
||||
+21
-2
@@ -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
|
||||
|
||||
+210
-21
@@ -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?,
|
||||
|
||||
+84
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-44
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-9
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+117
@@ -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 }
|
||||
}
|
||||
+78
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-13
@@ -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
|
||||
}
|
||||
|
||||
+15
-10
@@ -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()
|
||||
},
|
||||
) {
|
||||
|
||||
+29
-7
@@ -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) {
|
||||
|
||||
+72
-24
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -226,6 +226,10 @@ private fun RelayGroupMetadataScaffold(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
GroupMetadataFields(viewModel)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
ParentGroupSection(viewModel, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+491
@@ -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
|
||||
}
|
||||
+158
@@ -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
|
||||
}
|
||||
}
|
||||
+171
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -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))
|
||||
|
||||
+8
-2
@@ -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()
|
||||
|
||||
|
||||
+65
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+69
-7
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & wideo</string>
|
||||
<string name="resource_usage_subsystem_uploads">Wgrane</string>
|
||||
<string name="resource_usage_subsystem_money">Portfel & 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 & 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>
|
||||
|
||||
@@ -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 & 小型群组</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">简单 & 可发现</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">聊天 & 群组</string>
|
||||
<string name="bottom_bar_category_you">您</string>
|
||||
<string name="bottom_bar_category_feeds">订阅源</string>
|
||||
<string name="bottom_bar_category_apps">Apps & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
+75
@@ -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
|
||||
|
||||
+91
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -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) }
|
||||
}
|
||||
}
|
||||
+52
-15
@@ -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)
|
||||
|
||||
+29
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+121
@@ -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())
|
||||
}
|
||||
}
|
||||
+93
@@ -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)
|
||||
}
|
||||
}
|
||||
+65
@@ -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()
|
||||
|
||||
+154
@@ -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)
|
||||
}
|
||||
}
|
||||
-13
@@ -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
-18
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+36
-2
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+78
-5
@@ -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
|
||||
|
||||
+29
@@ -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"
|
||||
+54
@@ -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,
|
||||
|
||||
+70
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+14
-3
@@ -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) {
|
||||
|
||||
+34
@@ -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 }
|
||||
+8
-2
@@ -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) {
|
||||
|
||||
+12
@@ -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>()
|
||||
|
||||
+8
@@ -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(
|
||||
|
||||
+1
@@ -186,6 +186,7 @@ fun DesktopMessagesScreen(
|
||||
showNewDmDialog = false
|
||||
},
|
||||
onDismiss = { showNewDmDialog = false },
|
||||
onPrewarmDmRelays = { (account as? DesktopIAccount)?.prewarmDmInboxRelays(it) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -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 }
|
||||
|
||||
+14
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+94
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
+99
@@ -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() })
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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())
|
||||
}
|
||||
}
|
||||
@@ -122,18 +122,18 @@
|
||||
"Vietnamese"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "rajs19420616",
|
||||
"languages": [
|
||||
"Hindi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "maxblake2015",
|
||||
"languages": [
|
||||
"Polish"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "rajs19420616",
|
||||
"languages": [
|
||||
"Hindi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "summoner001",
|
||||
"languages": [
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+49
@@ -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
|
||||
|
||||
-113
@@ -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>>?,
|
||||
)
|
||||
-150
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -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"),
|
||||
)
|
||||
}
|
||||
+13
@@ -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,
|
||||
|
||||
+2
@@ -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}" }
|
||||
}
|
||||
}
|
||||
|
||||
+104
@@ -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)
|
||||
}
|
||||
}
|
||||
+81
@@ -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"
|
||||
}
|
||||
}
|
||||
+12
@@ -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(
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+57
@@ -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() }
|
||||
}
|
||||
}
|
||||
+102
@@ -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
|
||||
}
|
||||
}
|
||||
+22
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+69
@@ -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
Reference in New Issue
Block a user