mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/relay-auth-cache-91pa8f
# Conflicts: # amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
This commit is contained in:
@@ -51,6 +51,12 @@ Shipped as designed. Where it diverged or went further:
|
||||
connection forever. A second challenge for the same (relay, account) rides along
|
||||
on the owner's answer with no deadline of its own — running one would let it
|
||||
resolve the shared deferred and tear down a dialog mid-read.
|
||||
- **Corrected later:** this plan left the decision model alone, including the
|
||||
blanket `isFirstParty` gate on `CUSTOM`. That gate turned out to make
|
||||
`readFollows` ("…I'm reading someone I follow") unreachable — a follow's outbox
|
||||
relay is theirs, so it is never first-party for us, and every follow produced a
|
||||
prompt with the toggle explicitly on. `RelayAuthResolver.customAllows` now
|
||||
checks that one category ahead of the gate; the other three still require it.
|
||||
- **Still not done:** what a timeout should *look like*. It is now an honest 60s of
|
||||
visible time rather than a clock the user never saw, but it is still a dialog
|
||||
that vanishes and an event left pending in the outbox with no feedback. That
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.service.logging.Logging
|
||||
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
|
||||
import com.vitorpamplona.amethyst.service.priority.WorkerThreadPriorityGovernor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.LogLevel
|
||||
@@ -119,6 +120,11 @@ class Amethyst : Application() {
|
||||
|
||||
instance = AppModules(this)
|
||||
|
||||
// Keeps the ~650 relay/ingest worker threads a cold start spawns from starving the UI
|
||||
// thread out of its frames — worth ~45% off time-to-first-paint on a release build.
|
||||
// Override or disable with the `amethyst_worker_nice` global setting.
|
||||
WorkerThreadPriorityGovernor.start(this)
|
||||
|
||||
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
|
||||
FavoriteAppsRegistry.init(this)
|
||||
|
||||
|
||||
@@ -287,18 +287,6 @@ class AppModules(
|
||||
}
|
||||
}
|
||||
|
||||
// Restore + persist held NIP-OA attestations across restarts (device-global). Eager (not
|
||||
// lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk.
|
||||
val buzzAttestationPrefs = BuzzAttestationPreferences(appContext, applicationIOScope)
|
||||
|
||||
// Restore + persist the joined Buzz workspace relays across restarts (device-global). Eager so
|
||||
// the app knows which relays to sync as workspaces on cold start (Buzz membership is
|
||||
// server-side; there is no join event to rebuild the set from).
|
||||
val buzzWorkspacePrefs = BuzzWorkspacePreferences(appContext, applicationIOScope)
|
||||
|
||||
// Restore + persist the user's starred Buzz workspace channels across restarts (device-global).
|
||||
val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope)
|
||||
|
||||
// Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a
|
||||
// deleted channel stays hidden across a restart even if the host relay re-announces a stale
|
||||
// kind-44100 for it (device-global; a delete is authoritative and terminal for everyone).
|
||||
@@ -905,6 +893,17 @@ class AppModules(
|
||||
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
|
||||
signerPermissionStore = signerPermissionStore,
|
||||
nip46ClientStore = nip46ClientStore,
|
||||
// Restore + persist the Buzz bookkeeping that has no Nostr event to rebuild from: the
|
||||
// joined workspace relays (so the app knows which relays to sync as workspaces on cold
|
||||
// start — Buzz membership is server-side) and the starred channels. Per account: the
|
||||
// joined set makes a relay first-party for NIP-42, and a star is personal.
|
||||
startBuzzPersistence = { account ->
|
||||
BuzzWorkspacePreferences(appContext, account.scope, account.pubKey, account.buzzWorkspaces)
|
||||
BuzzChannelStarPreferences(appContext, account.scope, account.pubKey, account.buzzChannelStars)
|
||||
// Eager like the rest, so a held NIP-OA attestation is loaded before this account's
|
||||
// first Buzz-relay AUTH rather than after it.
|
||||
BuzzAttestationPreferences(appContext, account.scope, account.pubKey, account.buzzAttestation)
|
||||
},
|
||||
)
|
||||
|
||||
val sessionManager =
|
||||
|
||||
@@ -214,6 +214,7 @@ private object PrefKeys {
|
||||
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
|
||||
const val DISMISSED_POLL_NOTE_IDS = "dismissed_poll_note_ids"
|
||||
const val DISMISSED_CHANNEL_INVITES = "dismissed_channel_invites"
|
||||
const val MUTED_PUBLIC_CHATS = "muted_public_chats"
|
||||
const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids"
|
||||
const val PENDING_ATTESTATIONS = "pending_attestations"
|
||||
|
||||
@@ -650,6 +651,7 @@ object LocalPreferences {
|
||||
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
|
||||
putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value)
|
||||
putStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, settings.dismissedChannelInvites.value)
|
||||
putStringSet(PrefKeys.MUTED_PUBLIC_CHATS, settings.mutedPublicChats.value)
|
||||
putString(
|
||||
PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS,
|
||||
JsonMapper.toJson(settings.viewedPollResultNoteIds.value),
|
||||
@@ -789,6 +791,7 @@ object LocalPreferences {
|
||||
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
|
||||
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
|
||||
val dismissedChannelInvites = getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null) ?: setOf()
|
||||
val mutedPublicChats = getStringSet(PrefKeys.MUTED_PUBLIC_CHATS, null) ?: setOf()
|
||||
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
|
||||
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
|
||||
|
||||
@@ -1048,6 +1051,7 @@ object LocalPreferences {
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
|
||||
dismissedChannelInvites = MutableStateFlow(dismissedChannelInvites),
|
||||
mutedPublicChats = MutableStateFlow(mutedPublicChats),
|
||||
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestationsResolved),
|
||||
backupNipA3PaymentTargets = latestPaymentTargetsResolved,
|
||||
|
||||
@@ -34,7 +34,10 @@ import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
|
||||
@@ -74,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
|
||||
import com.vitorpamplona.amethyst.logTime
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||
import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState
|
||||
import com.vitorpamplona.amethyst.model.buzz.ChannelInvitesState
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
|
||||
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
|
||||
@@ -407,6 +411,23 @@ class Account(
|
||||
// answered without a disk read. Backed by a per-account file (see AccountCacheState).
|
||||
val relayAuthPermissions = RelayAuthPermissionCache(relayAuthPermissionStore, scope)
|
||||
|
||||
// The `block/buzz` workspaces THIS account joined. Per account, not per device: the invite was
|
||||
// redeemed by this key and the relay grants membership to it alone — and this set makes the
|
||||
// relay first-party for NIP-42 (see AuthCoordinator.isFirstParty), so a device-global set would
|
||||
// hand every other logged-in account an automatic login on a workspace it never joined.
|
||||
// Restored/persisted per account by BuzzWorkspacePreferences (see AccountCacheState).
|
||||
val buzzWorkspaces = BuzzWorkspaces()
|
||||
|
||||
// The Buzz channels THIS account pinned. A star says which channels this user wants at the top
|
||||
// of the community view, so a shared set let one account reorder and badge every other one's
|
||||
// channel list. Restored/persisted per account by BuzzChannelStarPreferences.
|
||||
val buzzChannelStars = BuzzChannelStars()
|
||||
|
||||
// The NIP-OA attestation an owner issued to THIS account's key, attached to its Buzz-relay
|
||||
// AUTH so the relay grants virtual membership. Restored/persisted per account by
|
||||
// BuzzAttestationPreferences.
|
||||
val buzzAttestation = BuzzHeldAttestations(pubKey)
|
||||
|
||||
// The relays this account approved by answering the NIP-42 prompt *without* the "remember"
|
||||
// switch. Deliberately in-memory only: it dies with this Account (i.e. with the process, or at
|
||||
// logout), which is what makes it a session grant rather than a stored ALLOW.
|
||||
@@ -559,6 +580,21 @@ class Account(
|
||||
val relayGroupListDecryptionCache = RelayGroupListDecryptionCache(signer)
|
||||
val relayGroupList = RelayGroupListState(signer, cache, relayGroupListDecryptionCache, scope, settings)
|
||||
|
||||
/**
|
||||
* Buzz channels somebody else added me to that I haven't answered yet, projected from the cached
|
||||
* kind-44100/44101 verdicts. Account state rather than screen state because the notifications DAL
|
||||
* reads it to decide whether a cached 44100 is still a live question.
|
||||
*/
|
||||
val channelInvites =
|
||||
ChannelInvitesState(
|
||||
me = signer.pubKey,
|
||||
cache = cache,
|
||||
buzzWorkspaces = buzzWorkspaces,
|
||||
relayGroupList = relayGroupList,
|
||||
dismissed = settings.dismissedChannelInvites,
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
val concordChannelList = ConcordChannelListState(signer, cache, scope, settings)
|
||||
|
||||
/**
|
||||
@@ -711,11 +747,18 @@ class Account(
|
||||
// the history loader ([AccountNotificationsHistoryEoseManager]) binds its orchestrator to these.
|
||||
val notificationHistory = RelayLoadingCursors()
|
||||
|
||||
// Per-relay backward-paging cursors for the NIP-60 spending history (kind:7376): how far back each
|
||||
// outbox relay has been paged by until+limit. Same lifetime rule as notificationHistory — held here
|
||||
// so paging progress survives leaving and re-entering the wallet screen; the history loader
|
||||
// ([CashuWalletHistoryEoseManager]) binds its orchestrator to these.
|
||||
val cashuHistory = RelayLoadingCursors()
|
||||
|
||||
val cashuWalletState =
|
||||
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
|
||||
pubKey = signer.pubKey,
|
||||
signer = signer,
|
||||
cache = cache,
|
||||
client = client,
|
||||
scope = scope,
|
||||
outboxRelaysFlow = outboxRelays.flow,
|
||||
inboxRelaysFlow = notificationRelays.flow,
|
||||
@@ -1041,6 +1084,15 @@ class Account(
|
||||
sendNewAppSpecificData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Local state first, then publish. The local write is what every suppression point
|
||||
* reads, so it must not wait on the signer — publishing is best-effort sync.
|
||||
*/
|
||||
suspend fun toggleMutedPublicChat(channelId: String) {
|
||||
settings.toggleMutedPublicChat(channelId)
|
||||
sendNewAppSpecificData()
|
||||
}
|
||||
|
||||
suspend fun updateZapAmounts(
|
||||
amountSet: List<Long>,
|
||||
selectedZapType: LnZapEvent.ZapType,
|
||||
|
||||
@@ -330,6 +330,14 @@ class AccountSettings(
|
||||
* still lists you, and Leave (kind 9022) is the separate action that actually removes you.
|
||||
*/
|
||||
val dismissedChannelInvites: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
|
||||
/**
|
||||
* NIP-28 channel ids the user has silenced. Local device state ON PURPOSE, even
|
||||
* though it also syncs via NIP-78: the push dispatcher must answer "is this muted?"
|
||||
* during a cold start, before (or without) the settings blob having been decrypted —
|
||||
* for a NIP-55 account that decrypt is an Amber IPC round-trip that may never
|
||||
* complete in the background. See AppSpecificState.kt:70-75.
|
||||
*/
|
||||
val mutedPublicChats: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
|
||||
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
|
||||
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
|
||||
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
|
||||
@@ -1515,6 +1523,14 @@ class AccountSettings(
|
||||
backupAppSpecificData = appSettings
|
||||
syncedSettings.updateFrom(newSyncedSettings)
|
||||
|
||||
// Null means an older client rewrote the blob without this key — leave the
|
||||
// local set alone rather than treating "absent" as "unmute everything".
|
||||
// The decision lives in mergeMutedPublicChats so it is unit-testable; this
|
||||
// class cannot be constructed in a JVM test.
|
||||
mutedPublicChats.tryEmit(
|
||||
mergeMutedPublicChats(mutedPublicChats.value, newSyncedSettings.chats.mutedPublicChats),
|
||||
)
|
||||
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
@@ -1614,6 +1630,17 @@ class AccountSettings(
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
// ---
|
||||
// muted public chats
|
||||
// ---
|
||||
|
||||
fun toggleMutedPublicChat(channelId: String) {
|
||||
mutedPublicChats.update {
|
||||
if (channelId in it) it - channelId else it + channelId
|
||||
}
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
// ---
|
||||
// viewed poll results
|
||||
// ---
|
||||
|
||||
@@ -90,7 +90,7 @@ class AccountSyncedSettings(
|
||||
MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))),
|
||||
)
|
||||
|
||||
fun toInternal(): AccountSyncedSettingsInternal =
|
||||
fun toInternal(mutedPublicChats: Set<String>): AccountSyncedSettingsInternal =
|
||||
AccountSyncedSettingsInternal(
|
||||
reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value, reactions.reactionRowItems.value),
|
||||
zaps =
|
||||
@@ -120,7 +120,12 @@ class AccountSyncedSettings(
|
||||
),
|
||||
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
|
||||
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
|
||||
chats = AccountChatPreferencesInternal(chats.pinnedChatrooms.value.map { it.users.sorted() }),
|
||||
chats =
|
||||
AccountChatPreferencesInternal(
|
||||
chats.pinnedChatrooms.value.map { it.users.sorted() },
|
||||
// sorted so the serialized form is deterministic
|
||||
mutedPublicChats.sorted(),
|
||||
),
|
||||
proofOfWork =
|
||||
AccountPoWPreferencesInternal(
|
||||
proofOfWork.difficulty.value,
|
||||
|
||||
+10
@@ -242,4 +242,14 @@ class AccountChatPreferencesInternal(
|
||||
// pubkeys (hex) sorted ascending, so the serialized form is deterministic
|
||||
// regardless of set iteration order.
|
||||
var pinnedRooms: List<List<String>> = emptyList(),
|
||||
// NIP-28 channel ids (hex) whose notifications are silenced, sorted ascending
|
||||
// for the same determinism reason as pinnedRooms.
|
||||
//
|
||||
// NULLABLE ON PURPOSE. The default has to tell two cases apart:
|
||||
// null = key absent — an older client rewrote the blob and dropped it, so
|
||||
// the local mute set must be left alone.
|
||||
// [] = an explicit "unmute everything" from a client that knows the field.
|
||||
// A non-null default would collapse them and let an old client silently erase
|
||||
// the user's mutes on every launch. See updateAppSpecificData.
|
||||
var mutedPublicChats: List<String>? = null,
|
||||
)
|
||||
|
||||
@@ -72,7 +72,15 @@ class AccountZapActions(
|
||||
lnurl: String? = null,
|
||||
) = LnZapRequestEvent.create(
|
||||
zappedEvent = event,
|
||||
relays = account.nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
|
||||
// Where the provider should publish the receipt. Zapping group content pins that to the room's
|
||||
// host relay: the receipt belongs where the message it pays for lives, so the room can show it
|
||||
// and the recipient's group query can find it — and, for a private or closed group, so a
|
||||
// kind-9735 naming the room never lands on a relay outside it. Everything else keeps the
|
||||
// ordinary NIP-65 inbox routing.
|
||||
relays =
|
||||
account.cache.relayGroupHostsFor(event).ifEmpty {
|
||||
account.nip65RelayList.inboxFlow.value
|
||||
} + (additionalRelays ?: emptySet()),
|
||||
signer = account.signer,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
|
||||
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
@@ -111,6 +112,11 @@ class EventBroadcaster(
|
||||
val channelRelays = account.cache.getAnyChannel(event)?.relays()
|
||||
if (channelRelays != null && channelRelays.isNotEmpty()) return false
|
||||
|
||||
// A group-scoped event whose room this cache doesn't know yet: it still must not go to the
|
||||
// broadcast list. Its `h` tag names a room only its host can serve, so broadcasting it says
|
||||
// "I am in this group" to relays that can do nothing with the content.
|
||||
if (event.isGroupScoped()) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -143,6 +149,16 @@ class EventBroadcaster(
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
// NIP-29 group content, and everything that refers to it — a kind-9 message, a kind-1111 comment,
|
||||
// a like, a zap request — exists in a room on a host relay and nowhere else. The room's members
|
||||
// read it there; the author's outbox and the broadcast list can neither serve it to them nor do
|
||||
// anything else useful with it, and for a private or closed group publishing it there advertises
|
||||
// who is in which room. So the host wins outright rather than being one more relay in the union.
|
||||
// Same rule the group reply composer already applies (CommentPostViewModel), applied to every
|
||||
// group-scoped event instead of just that one path.
|
||||
val groupHosts = account.cache.relayGroupHostsFor(event)
|
||||
if (groupHosts.isNotEmpty()) return groupHosts
|
||||
|
||||
val includeBroadcast = wantsBroadcastRelays(event)
|
||||
val broadcastRelays = if (includeBroadcast) account.broadcastRelayList.flow.value else emptySet()
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
|
||||
@@ -750,6 +749,21 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
return relayGroupChannels.filter { key, _ -> key.id == groupId }.singleOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Every host relay of the NIP-29 group [event] is scoped to (its `h` tag), or an empty set when the
|
||||
* event carries no group scope or the group is unknown to this cache.
|
||||
*
|
||||
* Keyed by group id alone rather than by [GroupId]: an event about to be *sent* (a reaction, a zap
|
||||
* request, a comment) knows which room it belongs to but not which relay hosts it — that is exactly
|
||||
* what this resolves. Group ids are relay-minted UUIDs, so the same id on two hosts is a
|
||||
* theoretical case, and answering with both is the safe reading of it: the content reaches every
|
||||
* host that claims the room, and none that don't.
|
||||
*/
|
||||
fun relayGroupHostsFor(event: Event): Set<NormalizedRelayUrl> {
|
||||
val groupId = event.groupId() ?: return emptySet()
|
||||
return relayGroupChannels.filter { key, _ -> key.id == groupId }.mapTo(mutableSetOf()) { it.groupId.relayUrl }
|
||||
}
|
||||
|
||||
fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key)
|
||||
|
||||
fun getNoteIfExists(event: Event): Note? =
|
||||
@@ -2327,20 +2341,19 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
}
|
||||
|
||||
/**
|
||||
* A kind-44101 "you were removed from a channel". Consumed like any other Buzz event, then used to
|
||||
* withdraw any pending add-prompt for that channel: once the relay has taken the membership away
|
||||
* there is nothing left to accept, so leaving the card up would offer an action that cannot succeed.
|
||||
* A kind-44101 "you were removed from a channel". Stored like any other Buzz event and nothing more:
|
||||
* withdrawing the matching add-prompt is not a side effect of ingest but a consequence of the stored
|
||||
* event, since
|
||||
* [com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites.pendingInvites] resolves each
|
||||
* channel to its newest verdict. That ordering is what makes the two kinds arriving out of order —
|
||||
* routine on a re-subscribe, where the relay replays the whole history — produce the same answer as
|
||||
* them arriving in order.
|
||||
*/
|
||||
private fun consume(
|
||||
event: MemberRemovedNotificationEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
consumeBuzzRegularEvent(event, relay, wasVerified).also {
|
||||
val target = event.target() ?: return@also
|
||||
val channelId = event.channel() ?: return@also
|
||||
BuzzChannelInvites.remove(target, channelId)
|
||||
}
|
||||
): Boolean = consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
|
||||
/**
|
||||
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
|
||||
|
||||
@@ -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.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
|
||||
/**
|
||||
* The NIP-28 channel an event belongs to, or null when [event] is not one of the three
|
||||
* public-chat event types a channel row's newest event can be.
|
||||
*
|
||||
* This is THE definition of "which event identifies a public-chat room" — the row dispatch
|
||||
* (ChatroomHeaderCompose), the last-read route (ChatroomRowUnread.rowLastReadRoute), the
|
||||
* feed's row de-duplication (ChatroomListKnownFeedFilter) and the mute predicate below all
|
||||
* call it. It used to be copied into each of those by hand, and the copies drifted: one of
|
||||
* them matched only [ChannelMessageEvent], so a channel whose latest activity was a topic
|
||||
* edit silently lost its unread dot. Keep it a single function.
|
||||
*
|
||||
* Deliberately NOT `threadRootIdOrSelf()`. That returns the same channel id here — a
|
||||
* NIP-28 message's NIP-10 root marker IS its channel — but it means something else
|
||||
* (the NIP-51 "muted thread" key, which HIDES content). Keeping the two apart is what
|
||||
* stops "mute notifications" and "mute thread" from bleeding into each other.
|
||||
*
|
||||
* Matched on concrete types rather than the IsInPublicChatChannel interface, which the
|
||||
* channel-admin events ChannelHideMessageEvent/ChannelMuteUserEvent also implement: those
|
||||
* must fall through to null rather than be treated as room activity.
|
||||
*/
|
||||
fun publicChatChannelIdOf(event: Event?): HexKey? =
|
||||
when (event) {
|
||||
is ChannelMessageEvent, is ChannelMetadataEvent -> event.channelId()
|
||||
is ChannelCreateEvent -> event.id
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** True when [event] is a public-chat message in a channel the user has silenced. */
|
||||
fun isMutedPublicChatMessage(
|
||||
event: Event?,
|
||||
mutedChannels: Set<HexKey>,
|
||||
): Boolean {
|
||||
if (mutedChannels.isEmpty()) return false
|
||||
val channelId = publicChatChannelIdOf(event) ?: return false
|
||||
return channelId in mutedChannels
|
||||
}
|
||||
|
||||
/**
|
||||
* The inbound-sync decision for the mute set, kept separate from [AccountSettings] so it can be
|
||||
* tested: [AccountSettings] builds a default `AccountSyncedSettingsInternal`, whose language
|
||||
* preferences call `Resources.getSystem()`, so it cannot be constructed in a JVM unit test.
|
||||
*
|
||||
* [remote] is `null` when an older client rewrote the NIP-78 blob without the key. The local set
|
||||
* must survive that — and because `AppSpecificState` replays the cached backup event on every app
|
||||
* start, treating absent as empty would re-clear the user's mutes on every single launch.
|
||||
*
|
||||
* An explicitly empty list is different: it is a real "unmute everything" from a client that knows
|
||||
* the field, and is adopted.
|
||||
*/
|
||||
fun mergeMutedPublicChats(
|
||||
local: Set<HexKey>,
|
||||
remote: List<HexKey>?,
|
||||
): Set<HexKey> = remote?.toSet() ?: local
|
||||
+11
@@ -73,6 +73,13 @@ class AccountCacheState(
|
||||
val signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
|
||||
/** App-global store of connected NIP-46 client display + relay info. */
|
||||
val nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
|
||||
/**
|
||||
* Starts per-account persistence of the Buzz client-side bookkeeping that has no Nostr event to
|
||||
* rebuild from — the joined workspaces and the starred channels (restore now, mirror later
|
||||
* changes). A lambda because those stores need an Android `Context` and this class deliberately
|
||||
* takes none; no-op by default so tests and non-Android hosts build an Account without it.
|
||||
*/
|
||||
val startBuzzPersistence: (Account) -> Unit = { },
|
||||
) {
|
||||
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
|
||||
|
||||
@@ -286,6 +293,10 @@ class AccountCacheState(
|
||||
signerPermissionStore = signerPermissionStore,
|
||||
nip46ClientStore = nip46ClientStore,
|
||||
).also { newAccount ->
|
||||
// Per account, not per device: the joined set makes a relay first-party for NIP-42, so a
|
||||
// shared one hands every other logged-in account an automatic login on a workspace it
|
||||
// never joined, and a shared star set reorders everyone's channel list at once.
|
||||
startBuzzPersistence(newAccount)
|
||||
accounts.update { existingAccounts ->
|
||||
existingAccounts.plus(Pair(signer.pubKey, newAccount))
|
||||
}
|
||||
|
||||
+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.model.buzz
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.MembershipNotice
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.buzz.MembershipNotificationKinds
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
|
||||
/*
|
||||
* The cache-side view of the Buzz membership stream: everything that reads kind-44100/44101 out of
|
||||
* LocalCache instead of asking a relay for its own copy.
|
||||
*
|
||||
* The relay subscription lives in BuzzMembershipEoseManager, mounted with the rest of the account
|
||||
* loaders. Every consumer of the stream — the Notifications feed's invite cards and Buzz DM discovery —
|
||||
* observes the cache through here, so there is exactly one `#p=me` REQ per workspace relay for all of
|
||||
* them.
|
||||
*
|
||||
* This is model code, not UI: the notifications DAL reads it from `acceptableEvent`, so it must not
|
||||
* live under `ui/`.
|
||||
*/
|
||||
|
||||
/** Every membership verdict addressed to [me], as the cache observers want it. */
|
||||
fun membershipNoticeFilter(me: HexKey) =
|
||||
Filter(
|
||||
kinds = MembershipNotificationKinds,
|
||||
tags = mapOf("p" to listOf(me)),
|
||||
)
|
||||
|
||||
/**
|
||||
* The workspace relay that vouched for this notice.
|
||||
*
|
||||
* A note records every relay it was seen on, and a Buzz membership notification is only meaningful on
|
||||
* the relay that issued it — the channel UUID it names is that relay's. So prefer a relay we joined as a
|
||||
* workspace; fall back to whatever else delivered it, which keeps a notice usable when the workspace set
|
||||
* hasn't been restored from disk yet.
|
||||
*
|
||||
* [workspaces] is passed in rather than read from a singleton because the joined set is per account
|
||||
* (`Account.buzzWorkspaces`): whose workspaces to prefer is a question only the caller can answer.
|
||||
*/
|
||||
private fun Note.membershipRelay(workspaces: Set<NormalizedRelayUrl>): NormalizedRelayUrl? {
|
||||
val seen = relays
|
||||
if (seen.isEmpty()) return null
|
||||
return seen.firstOrNull { it in workspaces } ?: seen.first()
|
||||
}
|
||||
|
||||
/** Flattens a cached kind-44100/44101 into a [MembershipNotice], or null when it isn't usable. */
|
||||
fun Note.toMembershipNotice(workspaces: Set<NormalizedRelayUrl>): MembershipNotice? {
|
||||
val relay = membershipRelay(workspaces) ?: return null
|
||||
return when (val noteEvent = event) {
|
||||
is MemberAddedNotificationEvent ->
|
||||
noteEvent.channel()?.let {
|
||||
MembershipNotice(noteEvent.id, it, relay, noteEvent.actor(), noteEvent.createdAt, removed = false)
|
||||
}
|
||||
|
||||
is MemberRemovedNotificationEvent ->
|
||||
noteEvent.channel()?.let {
|
||||
MembershipNotice(noteEvent.id, it, relay, noteEvent.actor(), noteEvent.createdAt, removed = true)
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun List<Note>.toMembershipNotices(workspaces: Set<NormalizedRelayUrl>): List<MembershipNotice> = mapNotNull { it.toMembershipNotice(workspaces) }
|
||||
|
||||
private fun Note.isMembershipNoticeFor(me: HexKey): Boolean =
|
||||
when (val noteEvent = event) {
|
||||
is MemberAddedNotificationEvent -> noteEvent.target().equals(me, ignoreCase = true)
|
||||
is MemberRemovedNotificationEvent -> noteEvent.target().equals(me, ignoreCase = true)
|
||||
else -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Every membership verdict for [me] currently in the cache.
|
||||
*
|
||||
* Scanned off [LocalCache.notes] rather than read from an `observeNotes` snapshot, because that
|
||||
* snapshot cannot contain these kinds. `LocalCache.filter` only yields addressables plus notes whose
|
||||
* `kind.isRegular()` — and `isRegular()` is `> 0 && < 10_000`, so a Buzz 44100/44101 matches none of
|
||||
* its branches and the seed comes back empty every time. Live arrivals are fine (the observer's `new()`
|
||||
* applies no such gate), which is why a cold start looked correct: the observer registers before the
|
||||
* relay answers. What broke was any projection built *after* the events had landed — switching to
|
||||
* another account and back builds a fresh one, and `consumeRegularEvent` never re-notifies a duplicate,
|
||||
* so it would have stayed empty for the rest of the session.
|
||||
*
|
||||
* So the observer is kept purely as the change signal and this scan is the data. It is the same shape
|
||||
* `NotificationFeedFilter.feed()` uses over the same map, for the same reason.
|
||||
*/
|
||||
fun LocalCache.membershipNotices(
|
||||
me: HexKey,
|
||||
workspaces: Set<NormalizedRelayUrl>,
|
||||
): List<MembershipNotice> =
|
||||
notes
|
||||
.filterIntoSet { _, note -> note.isMembershipNoticeFor(me) }
|
||||
.toList()
|
||||
.toMembershipNotices(workspaces)
|
||||
|
||||
/**
|
||||
* The Buzz type of every channel whose kind-39000 the cache already holds, keyed by group id.
|
||||
*
|
||||
* Built from the metadata events themselves rather than from the [RelayGroupChannel]s they populate,
|
||||
* because the two are filled in at different moments. `LocalCache.consume(GroupMetadataEvent)` loads the
|
||||
* event onto its addressable note and wakes the cache observers *first*, and only then copies it into
|
||||
* the channel — so a projection woken by that very emission reads a channel that is still empty, gets
|
||||
* [ChannelClassification.UNKNOWN], and, because nothing emits a second time, stays wrong until an
|
||||
* unrelated membership notice happens to arrive. (It also covers the case where the channel is never
|
||||
* populated at all: `consume` only touches it when the event carried relay provenance.) Reading the
|
||||
* event that caused the emission cannot race with itself.
|
||||
*
|
||||
* Keyed by group id alone, without the host relay: this is a fallback for [classifyBuzzChannel], which
|
||||
* still prefers the relay-scoped channel whenever that one has already been filled in.
|
||||
*/
|
||||
fun buzzChannelTypes(metadataNotes: List<Note>): Map<String, ChannelClassification> {
|
||||
val types = HashMap<String, ChannelClassification>(metadataNotes.size)
|
||||
metadataNotes.forEach { note ->
|
||||
val metadata = note.event as? GroupMetadataEvent ?: return@forEach
|
||||
types[metadata.groupId()] =
|
||||
if (metadata.isBuzzDmChannel()) ChannelClassification.DM else ChannelClassification.NAMED
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
/**
|
||||
* What [cache] currently knows about a channel's type, from its kind-39000.
|
||||
*
|
||||
* [ChannelClassification.UNKNOWN] until the directory lands — callers decide what to do with that, and
|
||||
* the invite projection deliberately withholds rather than guessing (see
|
||||
* [com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites.pendingInvites]).
|
||||
*
|
||||
* [knownTypes] (from [buzzChannelTypes]) is consulted when the channel has no metadata yet, which is
|
||||
* what makes the answer stable at the instant the directory lands — see that function for why the
|
||||
* channel alone is not enough.
|
||||
*/
|
||||
fun classifyBuzzChannel(
|
||||
cache: LocalCache,
|
||||
channelId: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
knownTypes: Map<String, ChannelClassification> = emptyMap(),
|
||||
): ChannelClassification {
|
||||
val metadata =
|
||||
cache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.event
|
||||
?: return knownTypes[channelId] ?: ChannelClassification.UNKNOWN
|
||||
return if (metadata.isBuzzDmChannel()) ChannelClassification.DM else ChannelClassification.NAMED
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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.buzz
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* The channels somebody else added the viewer to that are still awaiting a decision.
|
||||
*
|
||||
* A pure projection of what the cache already holds — the relay's kind-44100/44101 membership verdicts
|
||||
* addressed to me, the channels' kind-39000 types, my kind-10009 joined list, and my local dismissals.
|
||||
* Nothing here asserts membership (the relay already granted that); it only decides whose call it is to
|
||||
* surface the channel.
|
||||
*
|
||||
* ### Account state, not screen state
|
||||
*
|
||||
* This hangs off [com.vitorpamplona.amethyst.model.Account] rather than a feed holder because the
|
||||
* notifications DAL reads it: `NotificationFeedFilter.acceptableEvent` consults [pendingByEventId] to
|
||||
* decide whether a cached kind-44100 is still a live question, and `convertToCard` uses the same map to
|
||||
* build the row. A projection only the UI could reach would have forced the DAL to re-derive it.
|
||||
*
|
||||
* ### Derived, not recorded
|
||||
*
|
||||
* This used to read a process-wide registry that the Buzz DM discovery pass wrote into and its
|
||||
* classification step deleted from. Because the deletion was remembered nowhere, any re-delivery of the
|
||||
* same kind-44100 re-added an invite that had already been withdrawn, and the prompt appeared and
|
||||
* disappeared on a loop. Deriving from the cache removes the second source of truth: the same events
|
||||
* always produce the same answer, in any order, however many times they arrive.
|
||||
*/
|
||||
@Stable
|
||||
class ChannelInvitesState(
|
||||
private val me: HexKey,
|
||||
private val cache: LocalCache,
|
||||
private val buzzWorkspaces: BuzzWorkspaces,
|
||||
relayGroupList: RelayGroupListState,
|
||||
dismissed: StateFlow<Set<String>>,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
/**
|
||||
* What every group whose kind-39000 has landed turns out to be, which is what makes an
|
||||
* [com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification.UNKNOWN] channel decidable.
|
||||
* Without this the projection would never recompute when the directory arrives.
|
||||
*
|
||||
* It carries the classification rather than merely signalling that it changed, and that is the
|
||||
* point: `LocalCache.consume(GroupMetadataEvent)` wakes this observer *before* it copies the event
|
||||
* into the [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel], so a
|
||||
* recompute that went back to the channel for the answer read one that was still empty, concluded
|
||||
* UNKNOWN, and — with nothing left to emit — kept the invite hidden until an unrelated membership
|
||||
* notice arrived. Device-confirmed: a kind-44100 followed 20s later by its kind-39000 produced no
|
||||
* card at all, and one unrelated kind-44101 made it appear instantly.
|
||||
*
|
||||
* De-duplicated on the map, so a busy account's metadata traffic still only re-runs the projection
|
||||
* when a group's type actually becomes known or changes.
|
||||
*/
|
||||
private val knownChannelTypes =
|
||||
cache
|
||||
.observeNotes(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
|
||||
.map { buzzChannelTypes(it) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
/**
|
||||
* The membership verdicts themselves, re-scanned only when something can actually change them.
|
||||
*
|
||||
* The scan walks every note in the cache, so it is deliberately NOT part of the combine below: the
|
||||
* three inputs there (dismissals, my kind-10009, known channel types) change what the notices *mean*
|
||||
* but never what they *are*, and folding them in would re-walk the whole cache on every list edit.
|
||||
*
|
||||
* The observer emission is the arrival signal — it cannot be the data, because `observeNotes`'
|
||||
* initial snapshot can't hold these kinds at all (see [membershipNotices]). The workspace set is the
|
||||
* second trigger: a notice's relay is resolved by preferring a joined workspace over whatever else
|
||||
* delivered it, and restore-from-disk can land after the cache already holds notices, changing which
|
||||
* relay a channel resolves against — and with it whether its kind-39000 is ever found.
|
||||
*/
|
||||
private val notices =
|
||||
combine(
|
||||
cache.observeNotes(membershipNoticeFilter(me)),
|
||||
buzzWorkspaces.flow,
|
||||
) { _, workspaces -> cache.membershipNotices(me, workspaces) }
|
||||
|
||||
/** Pending invites keyed by the kind-44100 that produced them — what the notifications DAL reads. */
|
||||
val pendingByEventId: StateFlow<Map<HexKey, BuzzChannelInvite>> =
|
||||
combine(
|
||||
notices,
|
||||
knownChannelTypes,
|
||||
dismissed,
|
||||
relayGroupList.liveRelayGroupList,
|
||||
) { verdicts, knownTypes, dismissals, joined ->
|
||||
BuzzChannelInvites.pendingInvitesByEventId(
|
||||
viewer = me,
|
||||
notices = verdicts,
|
||||
dismissed = dismissals,
|
||||
joined = joined.mapTo(HashSet()) { it.groupId },
|
||||
classify = { channelId, relay -> classifyBuzzChannel(cache, channelId, relay, knownTypes) },
|
||||
)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
/** The same set as a newest-first list, for surfaces that render it directly. */
|
||||
val flow: StateFlow<List<BuzzChannelInvite>> =
|
||||
pendingByEventId
|
||||
.map { it.values.sortedByDescending { invite -> invite.createdAt } }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
/** Whether this cached kind-44100 is still an unanswered question. Hot path — a map lookup. */
|
||||
fun isPending(eventId: HexKey) = eventId in pendingByEventId.value
|
||||
|
||||
fun inviteFor(eventId: HexKey): BuzzChannelInvite? = pendingByEventId.value[eventId]
|
||||
}
|
||||
+238
-8
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuProofBackfillFilters
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -35,6 +36,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
@@ -61,12 +64,14 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -99,6 +104,7 @@ class CashuWalletState(
|
||||
private val pubKey: HexKey,
|
||||
private val signer: NostrSigner,
|
||||
private val cache: LocalCache,
|
||||
private val client: INostrClient,
|
||||
private val scope: CoroutineScope,
|
||||
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
private val inboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
@@ -519,6 +525,150 @@ class CashuWalletState(
|
||||
if (ids.isNotEmpty()) removeEvents(ids)
|
||||
}
|
||||
}
|
||||
|
||||
// Page the full proof set back, once, as soon as we know there is a
|
||||
// wallet and where to ask about it. The live subscription above cannot
|
||||
// do this on its own — see [resyncProofsFromRelays].
|
||||
//
|
||||
// Gated on a wallet existing so an Account object that is only resident
|
||||
// to decrypt a pushed gift wrap never pages a wallet nobody has: that
|
||||
// is the same reason the wallet's relay subscription lives in
|
||||
// CashuWalletEoseManager rather than here.
|
||||
jobs +=
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val ready =
|
||||
withTimeoutOrNull(BACKFILL_READY_WAIT_MS) {
|
||||
_walletEvent.first { it != null }
|
||||
outboxRelaysFlow.first { it.isNotEmpty() }
|
||||
}
|
||||
if (ready == null) {
|
||||
Log.d("CashuWallet") { "No wallet + outbox relays within ${BACKFILL_READY_WAIT_MS}ms; skipping proof backfill" }
|
||||
} else {
|
||||
resyncProofsFromRelays()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Proof backfill — paging past the relay's REQ cap
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* True once a paged proof walk has completed for this session. Guards the
|
||||
* automatic backfill only; [resyncProofsFromRelays] with `force` ignores it.
|
||||
*/
|
||||
@Volatile private var proofBackfillDone = false
|
||||
|
||||
private val proofBackfillMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Re-download **every** kind:7375 this account ever published, by paging
|
||||
* each outbox relay with `until` cursors, and then reconcile the result
|
||||
* against the mints.
|
||||
*
|
||||
* ### Why this is needed
|
||||
*
|
||||
* [balanceSats] is a pure function of [_tokenEntries], which is a pure
|
||||
* function of the kind:7375 events we happen to hold. Those arrive over the
|
||||
* live wallet subscription, which sends one unbounded REQ per relay. A relay
|
||||
* answers an unbounded REQ with its own cap (NIP-11 `limitation.max_limit`,
|
||||
* or a hard-coded default) applied to the **newest** matching events — and
|
||||
* the same filter also asks for kind:7376 history, which outnumbers the
|
||||
* proofs by an order of magnitude on any wallet with a few hundred
|
||||
* transactions. The proofs that lose that race are the ones at mints the
|
||||
* user has not touched recently, so what drops off the bottom is precisely
|
||||
* the balance the user forgot they had.
|
||||
*
|
||||
* Nothing recovers from it afterwards: a capped page and a complete page
|
||||
* both just EOSE, and [PerUserEoseManager] records that EOSE as the `since`
|
||||
* for every later REQ to that relay, so the events below the cap are never
|
||||
* asked for again. The subset is stable across cold starts (same filter,
|
||||
* same cap, same events) but differs between devices whose relay set,
|
||||
* arrival order or uptime differ — which is why one account can read 39 sat
|
||||
* on one phone, 1443 on another and 2522 on a third, with none of them
|
||||
* being the wallet's actual balance.
|
||||
*
|
||||
* ### What this does
|
||||
*
|
||||
* `fetchAllPagesFromPool` walks each relay backwards page by page until a
|
||||
* page comes back empty, so the cap bounds a page instead of the download.
|
||||
* Events land in [LocalCache] through the client-wide `EventCollector`, but
|
||||
* we also index what we receive directly rather than waiting on the bundled
|
||||
* cache round-trip, so the balance is correct the moment the walk returns.
|
||||
*
|
||||
* ### Why the scrub afterwards
|
||||
*
|
||||
* Spent proofs are retired with a NIP-09 kind:5, and a relay that ignores
|
||||
* deletions will happily hand those kind:7375 events back on a paged walk.
|
||||
* Taken alone, this would trade an under-count for an over-count. So when
|
||||
* the walk actually recovered something, we finish with the NUT-07
|
||||
* [scrubLocallyStaleProofs] sweep: the mint — not the relay — decides which
|
||||
* proofs are still unspent, and anything it calls SPENT is dropped and
|
||||
* re-deleted. The sweep is skipped when the walk found nothing new, so a
|
||||
* steady-state launch costs no mint traffic.
|
||||
*
|
||||
* Returns the number of kind:7375 events the walk delivered that we did not
|
||||
* already hold, or null when it could not run (not started, no relays, or
|
||||
* already done and not forced).
|
||||
*/
|
||||
suspend fun resyncProofsFromRelays(force: Boolean = false): Int? {
|
||||
if (!started) return null
|
||||
if (proofBackfillDone && !force) return null
|
||||
return proofBackfillMutex.withLock {
|
||||
if (proofBackfillDone && !force) return@withLock null
|
||||
val relays = outboxRelaysFlow.value
|
||||
// Don't latch on an empty relay set — the NIP-65 list may simply not
|
||||
// have arrived yet, and the caller retries once it does.
|
||||
if (relays.isEmpty()) return@withLock null
|
||||
|
||||
val filters = cashuProofBackfillFilters(pubKey)
|
||||
// The callback runs on the relay reader thread and must not suspend,
|
||||
// so collect first and index after the walk.
|
||||
val collected = ConcurrentHashMap<HexKey, CashuTokenEvent>()
|
||||
runCatching {
|
||||
client.fetchAllPagesFromPool(
|
||||
filters = relays.associateWith { filters },
|
||||
idleTimeoutMs = BACKFILL_IDLE_TIMEOUT_MS,
|
||||
) { event, _ ->
|
||||
if (event is CashuTokenEvent && event.pubKey == pubKey) {
|
||||
collected.putIfAbsent(event.id, event)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w("CashuWallet", "Paged proof backfill failed", it)
|
||||
}.onSuccess {
|
||||
// Latch only on a walk that actually completed. A walk that
|
||||
// blew up (offline at launch, every relay unreachable) has
|
||||
// proved nothing about what the relays hold, and latching on it
|
||||
// would leave the wallet showing the truncated balance for the
|
||||
// rest of the session with no automatic second attempt.
|
||||
proofBackfillDone = true
|
||||
}
|
||||
|
||||
val fresh = collected.values.filter { !tokenEvents.containsKey(it.id) }
|
||||
Log.i("CashuWallet") {
|
||||
"Proof backfill over ${relays.size} relay(s): ${collected.size} kind:7375 seen, ${fresh.size} new"
|
||||
}
|
||||
|
||||
if (fresh.isNotEmpty()) {
|
||||
applyEvents(fresh)
|
||||
// A relay that ignores NIP-09 just handed back proofs the mint
|
||||
// already burned. Let the mint arbitrate before the user sees a
|
||||
// number.
|
||||
runCatching { scrubLocallyStaleProofs() }
|
||||
.onFailure { Log.w("CashuWallet", "Post-backfill NUT-07 sweep failed", it) }
|
||||
} else if (undecryptedTokenCount() > 0) {
|
||||
// Nothing new off the relays, but we are still holding proofs
|
||||
// we could not read. A decrypt failure hides money exactly as
|
||||
// effectively as a missing event does, and the retry inside
|
||||
// recomputeUnspent only fires when some *other* change marks
|
||||
// the tokens dirty — which, in a wallet that has gone quiet, may
|
||||
// be never. A user asking for a refresh is asking for that
|
||||
// retry too.
|
||||
recomputeUnspent()
|
||||
}
|
||||
fresh.size
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
@@ -704,8 +854,10 @@ class CashuWalletState(
|
||||
private suspend fun recomputeUnspent() {
|
||||
val all = tokenEvents.values.toList()
|
||||
// Decrypt anything we haven't seen before; reuse cached TokenContent
|
||||
// for events we've already decrypted. Decryption failures are
|
||||
// skipped — the proof set rebuilds the next time a re-key happens.
|
||||
// for events we've already decrypted. Only successes are cached, so a
|
||||
// failure is retried on the next recompute rather than being pinned as
|
||||
// "empty" for the session.
|
||||
var undecryptable = 0
|
||||
all.forEach { evt ->
|
||||
if (!tokenContents.containsKey(evt.id)) {
|
||||
val content =
|
||||
@@ -715,7 +867,19 @@ class CashuWalletState(
|
||||
"Failed to decrypt token ${evt.id.take(8)}: ${it.message}"
|
||||
}
|
||||
}.getOrNull()
|
||||
if (content != null) tokenContents[evt.id] = content
|
||||
if (content != null) tokenContents[evt.id] = content else undecryptable++
|
||||
}
|
||||
}
|
||||
|
||||
// A token we cannot decrypt is money we cannot see, and it drops out of
|
||||
// the balance as silently as a token a relay never delivered. The
|
||||
// retry above only fires when something else triggers a recompute, so
|
||||
// say it out loud: with this counter, a wallet reading low because an
|
||||
// external signer refused N decrypts is diagnosable from a log instead
|
||||
// of looking identical to a wallet that is genuinely empty.
|
||||
if (undecryptable > 0) {
|
||||
Log.w("CashuWallet") {
|
||||
"$undecryptable of ${all.size} kind:7375 event(s) failed to decrypt — balance excludes them"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,6 +887,9 @@ class CashuWalletState(
|
||||
_tokenEntries.value = CashuWalletReader.computeUnspent(all, tokenContents)
|
||||
}
|
||||
|
||||
/** Token events we hold but have never managed to decrypt. See [recomputeUnspent]. */
|
||||
private fun undecryptedTokenCount(): Int = tokenEvents.keys.count { it !in tokenContents.keys }
|
||||
|
||||
private fun recomputePending() {
|
||||
// Shared destroyed/expired filter with the headless reader.
|
||||
_pendingQuotes.value = CashuWalletReader.computePending(quoteEvents.values, historyEvents.values)
|
||||
@@ -747,8 +914,14 @@ class CashuWalletState(
|
||||
private suspend fun redeemPendingNutzapsSerialized() {
|
||||
if (!redeemMutex.tryLock()) return // a sweep is already in flight
|
||||
try {
|
||||
val privkey = walletPrivkeyHex() ?: return
|
||||
val pubkey = p2pkPubkeyHex() ?: return
|
||||
// Establish there is work BEFORE touching the signer. This sweep
|
||||
// fires from every relevant cache bundle, and the two key reads
|
||||
// below are NIP-44 decrypts of kind:17375 — for a NIP-46 bunker or
|
||||
// a NIP-55 external signer that is a round-trip out of the process
|
||||
// (Amber even prompts on some configurations), paid on every bundle
|
||||
// by a wallet whose nutzaps were all redeemed months ago. Nothing
|
||||
// above the candidate filter needs a key, so hoist the filter.
|
||||
if (nutzapEvents.isEmpty()) return
|
||||
val skipIds = HashSet<HexKey>()
|
||||
historyEvents.values.forEach { h ->
|
||||
h.redeemedReferences().forEach { skipIds.add(it.eventId) }
|
||||
@@ -759,6 +932,17 @@ class CashuWalletState(
|
||||
val candidates = nutzapEvents.values.filter { it.id !in skipIds }
|
||||
if (candidates.isEmpty()) return
|
||||
|
||||
val privkey = walletPrivkeyHex() ?: return
|
||||
// Derived from the same key the line above just decrypted — pass it
|
||||
// in rather than letting p2pkPubkeyHex() decrypt kind:17375 a
|
||||
// second time for the identical bytes.
|
||||
val pubkey =
|
||||
runCatching {
|
||||
Secp256k1
|
||||
.pubKeyCompress(Secp256k1.pubkeyCreate(privkey.hexToByteArray()))
|
||||
.toHexKey()
|
||||
}.getOrNull() ?: return
|
||||
|
||||
for (ev in candidates) {
|
||||
try {
|
||||
ops.redeemNutzap(ev, privkey, pubkey)
|
||||
@@ -918,11 +1102,26 @@ class CashuWalletState(
|
||||
val sharedMints = info.mints().map { it.mintUrl }.filter { it in ourMints }
|
||||
if (sharedMints.isEmpty()) return null
|
||||
|
||||
// One pass over the entries, not one per shared mint. This runs inside
|
||||
// a composable `remember {}` on every zap chip, so it is per rendered
|
||||
// note — and `_tokenEntries` is no longer the handful of events a
|
||||
// truncated relay delivery used to leave behind, it is the wallet's
|
||||
// whole proof set. The old filter-per-mint form was
|
||||
// O(sharedMints × entries) with a throwaway list allocated per mint.
|
||||
val entries = _tokenEntries.value
|
||||
val satsPerMint = HashMap<String, Long>(sharedMints.size)
|
||||
var totalWalletSats = 0L
|
||||
entries.forEach { entry ->
|
||||
val amount = entry.content.totalAmount()
|
||||
totalWalletSats += amount
|
||||
val mint = entry.content.mint
|
||||
if (mint in ourMints) satsPerMint[mint] = (satsPerMint[mint] ?: 0L) + amount
|
||||
}
|
||||
|
||||
var bestMint = sharedMints.first()
|
||||
var bestMintSats = 0L
|
||||
for (mint in sharedMints) {
|
||||
val balance = entries.filter { it.content.mint == mint }.sumOf { it.content.totalAmount() }
|
||||
val balance = satsPerMint[mint] ?: 0L
|
||||
if (balance > bestMintSats) {
|
||||
bestMintSats = balance
|
||||
bestMint = mint
|
||||
@@ -932,7 +1131,7 @@ class CashuWalletState(
|
||||
return NutzapFunding(
|
||||
target = NutzapTarget(mintUrl = bestMint, recipientP2pkPubkeyHex = recipientPubkeyHex),
|
||||
bestSingleMintSats = bestMintSats,
|
||||
totalWalletSats = entries.sumOf { it.content.totalAmount() },
|
||||
totalWalletSats = totalWalletSats,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1203,11 +1402,26 @@ class CashuWalletState(
|
||||
entry to entry.content.proofs.mapTo(HashSet()) { it.secret }
|
||||
}
|
||||
|
||||
// Index secret → entries holding it. A superset of B must share every
|
||||
// one of B's secrets, so the only entries that can possibly cover B are
|
||||
// the ones indexed under B's first secret — which is a handful, not the
|
||||
// whole wallet. The previous all-pairs scan was O(entries²) with a
|
||||
// set-containment test inside; that was invisible while a truncated
|
||||
// relay delivery kept the wallet at a few entries, and is not once the
|
||||
// whole proof set is present.
|
||||
val holdersOfSecret = HashMap<String, MutableList<Pair<TokenEntry, HashSet<String>>>>()
|
||||
withSecrets.forEach { pair ->
|
||||
pair.second.forEach { secret ->
|
||||
holdersOfSecret.getOrPut(secret) { mutableListOf() }.add(pair)
|
||||
}
|
||||
}
|
||||
|
||||
val redundant = mutableListOf<TokenEntry>()
|
||||
for ((entry, secrets) in withSecrets) {
|
||||
if (secrets.isEmpty()) continue
|
||||
val candidates = holdersOfSecret[secrets.first()] ?: continue
|
||||
val isRedundant =
|
||||
withSecrets.any { (other, otherSecrets) ->
|
||||
candidates.any { (other, otherSecrets) ->
|
||||
other.event.id != entry.event.id &&
|
||||
otherSecrets.containsAll(secrets) &&
|
||||
(
|
||||
@@ -1567,6 +1781,22 @@ class CashuWalletState(
|
||||
*/
|
||||
const val DISCOVERY_TIMEOUT_MS = 8_000L
|
||||
|
||||
/**
|
||||
* How long the startup proof backfill waits for a wallet event plus a
|
||||
* non-empty outbox relay set before giving up. Both are restored from
|
||||
* AccountSettings almost immediately on a returning launch; this window
|
||||
* only matters on a first sign-in, where they have to come off the
|
||||
* network before we know there is a wallet and where its events live.
|
||||
*/
|
||||
private const val BACKFILL_READY_WAIT_MS = 60_000L
|
||||
|
||||
/**
|
||||
* Per-page idle window for the paged proof walk — measured from the
|
||||
* relay's last message, not from the page's start, so a relay actively
|
||||
* streaming a long backlog is never cut off mid-page.
|
||||
*/
|
||||
private const val BACKFILL_IDLE_TIMEOUT_MS = 30_000L
|
||||
|
||||
private const val NOT_STARTED_MESSAGE = "CashuWalletState.start() not called"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ class AppSpecificState(
|
||||
fun getAppSpecificDataFlow(): StateFlow<NoteState> = amethystSettingsNote.flow().metadata.stateFlow
|
||||
|
||||
suspend fun saveNewAppSpecificData(): AppSpecificDataEvent {
|
||||
val toInternal = settings.syncedSettings.toInternal()
|
||||
val toInternal = settings.syncedSettings.toInternal(settings.mutedPublicChats.value)
|
||||
return signer.sign(
|
||||
AppSpecificDataEvent.build(
|
||||
dTag = APP_SPECIFIC_DATA_D_TAG,
|
||||
|
||||
+72
-26
@@ -37,25 +37,36 @@ import kotlinx.serialization.json.Json
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Device-global persistence for the NIP-OA attestations this device holds
|
||||
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of
|
||||
* needing to be re-pasted. Uses the app-wide [sharedPreferencesDataStore] like
|
||||
* [NamecoinSharedPreferences] (not per-account — the store is already keyed by the agent
|
||||
* pubkey each attestation authorizes).
|
||||
* Per-account persistence for the NIP-OA attestation this account holds
|
||||
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of needing to be
|
||||
* re-pasted. The key is namespaced by pubkey; the store used to be one device-global list because
|
||||
* each entry carried the agent key it authorized, which made the file a per-account store with
|
||||
* extra steps.
|
||||
*
|
||||
* On construction it loads the saved entries into the singleton — **re-verifying each
|
||||
* against its agent key**, so a tampered on-disk credential is dropped rather than trusted
|
||||
* — then mirrors every later change back to disk. Construct once, eagerly, at startup.
|
||||
* On construction it loads this account's saved attestation and mirrors every later change back to
|
||||
* disk. Re-verification on restore is no longer done here: [BuzzHeldAttestations.put] verifies
|
||||
* against the agent key itself and rejects what fails, so a tampered on-disk credential is dropped
|
||||
* by the same gate that rejects a mistyped one. Construct once per account, eagerly.
|
||||
*/
|
||||
@Stable
|
||||
class BuzzAttestationPreferences(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKeyHex: HexKey,
|
||||
private val attestation: BuzzHeldAttestations,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val key = stringPreferencesKey("$KEY_PREFIX$pubKeyHex")
|
||||
|
||||
@Serializable
|
||||
private data class Entry(
|
||||
val owner: HexKey,
|
||||
val conditions: String,
|
||||
val sig: HexKey,
|
||||
)
|
||||
|
||||
/** The pre-namespacing on-disk shape: one list for the whole device, each entry agent-keyed. */
|
||||
@Serializable
|
||||
private data class LegacyEntry(
|
||||
val agent: HexKey,
|
||||
val owner: HexKey,
|
||||
val conditions: String,
|
||||
@@ -67,41 +78,76 @@ class BuzzAttestationPreferences(
|
||||
restoreFromDisk()
|
||||
// Persist on every change AFTER the initial restore (drop(1) skips the value
|
||||
// present at collection start, which restoreFromDisk already wrote).
|
||||
BuzzHeldAttestations.flow.drop(1).collect { persist(it) }
|
||||
attestation.flow.drop(1).collect { persist(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreFromDisk() {
|
||||
try {
|
||||
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
|
||||
val verified =
|
||||
json
|
||||
.decodeFromString<List<Entry>>(raw)
|
||||
.mapNotNull { e ->
|
||||
val attestation = OwnerAttestation(e.owner, e.conditions, e.sig)
|
||||
// Only reinstate a credential that still verifies for its agent key.
|
||||
if (attestation.verify(e.agent)) e.agent to attestation else null
|
||||
}.toMap()
|
||||
if (verified.isNotEmpty()) BuzzHeldAttestations.restore(verified)
|
||||
val prefs = context.sharedPreferencesDataStore.data.first()
|
||||
// put() verifies, so a credential that no longer checks out is dropped either way.
|
||||
restoreFrom(prefs[key], prefs[LEGACY_KEY], pubKeyHex)?.let(attestation::put)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("BuzzAttestationPrefs") { "Error reading held attestations: ${e.message}" }
|
||||
Log.e("BuzzAttestationPrefs") { "Error reading held attestation: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persist(entries: Map<HexKey, OwnerAttestation>) {
|
||||
private suspend fun persist(held: OwnerAttestation?) {
|
||||
try {
|
||||
val list = entries.map { (agent, a) -> Entry(agent, a.ownerPubKey, a.conditions, a.sig) }
|
||||
context.sharedPreferencesDataStore.edit { prefs ->
|
||||
prefs[KEY] = json.encodeToString(list)
|
||||
// Write [NONE] rather than removing the key: removing it is indistinguishable from
|
||||
// never having migrated, which would let the legacy list re-seed a credential the
|
||||
// user just deleted. See [restoreFrom].
|
||||
prefs[key] = if (held == null) NONE else json.encodeToString(Entry(held.ownerPubKey, held.conditions, held.sig))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("BuzzAttestationPrefs") { "Error writing held attestations: ${e.message}" }
|
||||
Log.e("BuzzAttestationPrefs") { "Error writing held attestation: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY = stringPreferencesKey("buzz.heldAttestations")
|
||||
private const val KEY_PREFIX = "buzz.heldAttestation."
|
||||
|
||||
/** The device-global key written before the store became per-account; read-only now. */
|
||||
private val LEGACY_KEY = stringPreferencesKey("buzz.heldAttestations")
|
||||
|
||||
/**
|
||||
* Tombstone for "this account has been migrated and holds nothing", which an *absent* key
|
||||
* cannot express — absent still means "never migrated" and is allowed to seed from
|
||||
* [LEGACY_KEY]. Without it, removing a held attestation lasted only until the next launch,
|
||||
* because nothing ever clears the legacy list. (The starred-channel and joined-workspace
|
||||
* stores get this for free: they persist an empty *set*, which reads back present.)
|
||||
*/
|
||||
private const val NONE = ""
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Which attestation to reinstate, given this account's saved value and the pre-namespacing
|
||||
* device-global list. Pure, so the migration precedence is testable without a `Context`.
|
||||
*
|
||||
* [saved] wins whenever it is present, [NONE] included. Only a never-migrated account falls
|
||||
* back to [legacy], and it takes just the entry issued to its own key — that list was
|
||||
* already agent-keyed, so no other account's credential can match. Nothing is verified here;
|
||||
* [BuzzHeldAttestations.put] is the gate that rejects a tampered credential.
|
||||
*/
|
||||
internal fun restoreFrom(
|
||||
saved: String?,
|
||||
legacy: String?,
|
||||
agentPubKey: HexKey,
|
||||
): OwnerAttestation? {
|
||||
if (saved != null) {
|
||||
if (saved == NONE) return null
|
||||
val entry = json.decodeFromString<Entry>(saved)
|
||||
return OwnerAttestation(entry.owner, entry.conditions, entry.sig)
|
||||
}
|
||||
val list = legacy ?: return null
|
||||
return json
|
||||
.decodeFromString<List<LegacyEntry>>(list)
|
||||
.firstOrNull { it.agent == agentPubKey }
|
||||
?.let { OwnerAttestation(it.owner, it.conditions, it.sig) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-9
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.drop
|
||||
@@ -33,28 +34,39 @@ import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Device-global persistence for the set of starred Buzz workspace channels ([BuzzChannelStars]),
|
||||
* so favorites survive a restart. Mirrors [BuzzWorkspacePreferences]: app-wide (not per-account),
|
||||
* loads the saved ids into the singleton on construction, then writes every later change back.
|
||||
* Construct once, eagerly.
|
||||
* Per-account persistence for the set of starred Buzz workspace channels ([BuzzChannelStars]), so
|
||||
* favorites survive a restart. Mirrors [BuzzWorkspacePreferences] in both shape and reasoning: the
|
||||
* key is namespaced by pubkey because a star is personal — it says which channels *this* user wants
|
||||
* pinned — and one device-global set meant one account's favorites reordered and badged every other
|
||||
* logged-in account's channel list. Loads this account's saved ids into [stars] on construction,
|
||||
* then writes every later change back. Construct once per account, eagerly.
|
||||
*/
|
||||
@Stable
|
||||
class BuzzChannelStarPreferences(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKeyHex: HexKey,
|
||||
private val stars: BuzzChannelStars,
|
||||
) {
|
||||
private val key = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex")
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
restoreFromDisk()
|
||||
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
|
||||
BuzzChannelStars.flow.drop(1).collect { persist(it) }
|
||||
stars.flow.drop(1).collect { persist(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreFromDisk() {
|
||||
try {
|
||||
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
|
||||
if (raw.isNotEmpty()) BuzzChannelStars.restore(raw)
|
||||
val prefs = context.sharedPreferencesDataStore.data.first()
|
||||
// Fall back to the pre-namespacing device-global key so an upgrade doesn't unpin
|
||||
// everything. That set is what every account already saw; the next toggle writes to this
|
||||
// account's own key and takes over. The legacy key is left for other accounts to seed
|
||||
// from and is never written again.
|
||||
val raw = prefs[key] ?: prefs[LEGACY_KEY] ?: return
|
||||
if (raw.isNotEmpty()) stars.restore(raw)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("BuzzChannelStarPrefs") { "Error reading starred channels: ${e.message}" }
|
||||
@@ -63,7 +75,10 @@ class BuzzChannelStarPreferences(
|
||||
|
||||
private suspend fun persist(ids: Set<String>) {
|
||||
try {
|
||||
context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = ids }
|
||||
// Always write the starred set, empty included — never remove the key. An absent key
|
||||
// means "never migrated" and re-seeds from the legacy one above, so removing it
|
||||
// would undo the user's last removal on the next launch.
|
||||
context.sharedPreferencesDataStore.edit { prefs -> prefs[key] = ids }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("BuzzChannelStarPrefs") { "Error writing starred channels: ${e.message}" }
|
||||
@@ -71,6 +86,9 @@ class BuzzChannelStarPreferences(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY = stringSetPreferencesKey("buzz.starredChannels")
|
||||
private const val KEY_PREFIX = "buzz.starredChannels."
|
||||
|
||||
/** The device-global key written before the set became per-account; read-only now. */
|
||||
private val LEGACY_KEY = stringSetPreferencesKey("buzz.starredChannels")
|
||||
}
|
||||
}
|
||||
|
||||
+41
-14
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -35,36 +36,56 @@ import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Device-global persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]),
|
||||
* so the app knows which relays to connect + NIP-42-authenticate + run member-channel discovery
|
||||
* against on a cold start — Buzz membership is server-side (granted by the HTTP invite claim),
|
||||
* with no NIP-51/kind-10009 join event to rebuild the set from. Uses the app-wide
|
||||
* [sharedPreferencesDataStore] like [BuzzAttestationPreferences] (not per-account: a joined
|
||||
* relay is workspace-wide, and restoring only marks relays to sync — the relay still gates every
|
||||
* read/write by the authenticated key).
|
||||
* Per-account persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]), so the
|
||||
* app knows which relays to connect + NIP-42-authenticate + run member-channel discovery against on
|
||||
* a cold start — Buzz membership is server-side (granted by the HTTP invite claim), with no
|
||||
* NIP-51/kind-10009 join event to rebuild the set from.
|
||||
*
|
||||
* On construction it loads the saved relay URLs into the singleton (re-normalizing each, dropping
|
||||
* any that no longer parse), then mirrors every later change back to disk. Construct once, eagerly.
|
||||
* **Per account, not per device.** The set used to be one device-global key shared by every logged-in
|
||||
* account, on the reasoning that restoring only marks relays to sync and the relay gates each
|
||||
* read/write by the authenticated key anyway. That missed one consumer: the joined set also makes a
|
||||
* relay first-party in `AuthCoordinator.isFirstParty`, so one account joining a workspace silently
|
||||
* gave *every* other logged-in account an automatic NIP-42 login there — the bystander-account leak
|
||||
* the per-account gate exists to prevent. The key is namespaced by pubkey for the same reason the
|
||||
* relay-auth overrides moved to a per-account file.
|
||||
*
|
||||
* Still on the app-wide [sharedPreferencesDataStore] file — the namespacing, not the file, is what
|
||||
* separates accounts, and one file avoids a second DataStore per logged-in account.
|
||||
*
|
||||
* On construction it loads this account's saved relay URLs into [workspaces] (re-normalizing each,
|
||||
* dropping any that no longer parse), then mirrors every later change back to disk. Construct once
|
||||
* per account, eagerly.
|
||||
*/
|
||||
@Stable
|
||||
class BuzzWorkspacePreferences(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKeyHex: HexKey,
|
||||
private val workspaces: BuzzWorkspaces,
|
||||
) {
|
||||
private val key = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex")
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
restoreFromDisk()
|
||||
// Persist on every change AFTER the initial restore (drop(1) skips the value present
|
||||
// at collection start, which restoreFromDisk already wrote).
|
||||
BuzzWorkspaces.flow.drop(1).collect { persist(it) }
|
||||
workspaces.flow.drop(1).collect { persist(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreFromDisk() {
|
||||
try {
|
||||
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
|
||||
val prefs = context.sharedPreferencesDataStore.data.first()
|
||||
// Fall back to the pre-namespacing device-global key so an upgrade doesn't empty the
|
||||
// workspaces hub. That set is whatever any account joined, which is exactly what every
|
||||
// account already saw before this became per-account — so seeding from it changes
|
||||
// nothing that was true yesterday, and the first join after the upgrade writes to this
|
||||
// account's own key and takes over. The legacy key is left in place for the other
|
||||
// accounts to seed from; nothing writes it again.
|
||||
val raw = prefs[key] ?: prefs[LEGACY_KEY] ?: return
|
||||
val relays = raw.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
if (relays.isNotEmpty()) BuzzWorkspaces.restore(relays)
|
||||
if (relays.isNotEmpty()) workspaces.restore(relays)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("BuzzWorkspacePrefs") { "Error reading joined workspaces: ${e.message}" }
|
||||
@@ -73,8 +94,11 @@ class BuzzWorkspacePreferences(
|
||||
|
||||
private suspend fun persist(relays: Set<NormalizedRelayUrl>) {
|
||||
try {
|
||||
// Always write the joined set, empty included — never remove the key. An absent key
|
||||
// means "never migrated" and re-seeds from the legacy one above, so removing it
|
||||
// would undo the user's last removal on the next launch.
|
||||
context.sharedPreferencesDataStore.edit { prefs ->
|
||||
prefs[KEY] = relays.map { it.url }.toSet()
|
||||
prefs[key] = relays.map { it.url }.toSet()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
@@ -83,6 +107,9 @@ class BuzzWorkspacePreferences(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY = stringSetPreferencesKey("buzz.joinedWorkspaces")
|
||||
private const val KEY_PREFIX = "buzz.joinedWorkspaces."
|
||||
|
||||
/** The device-global key written before the set became per-account; read-only now. */
|
||||
private val LEGACY_KEY = stringSetPreferencesKey("buzz.joinedWorkspaces")
|
||||
}
|
||||
}
|
||||
|
||||
+19
-3
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.isMutedPublicChatMessage
|
||||
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
|
||||
import com.vitorpamplona.amethyst.service.notifications.renderers.ArticleNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.renderers.BadgeNotification
|
||||
@@ -218,11 +219,20 @@ class EventNotificationConsumer(
|
||||
// Don't push-notify events this account authored.
|
||||
if (event.pubKey == account.signer.pubKey) return
|
||||
|
||||
// Drop reactions/zaps/reposts whose target note lives on a muted thread
|
||||
// (matches the in-app feed, which mutes all four).
|
||||
// Drop reactions/zaps/reposts whose target note lives on a muted thread, or in a
|
||||
// public chat the user has silenced (matches the in-app feed, which mutes all four).
|
||||
// Without the second check, muting a channel still let a like on your own message
|
||||
// there notify you — the row's glyph promises silence, so it has to mean it.
|
||||
if (event is ReactionEvent || event is LnZapEvent || event is RepostEvent || event is GenericRepostEvent) {
|
||||
val target = LocalCache.getNoteIfExists(event)?.replyTo?.lastOrNull()
|
||||
if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) return
|
||||
if (target != null &&
|
||||
(
|
||||
account.isThreadMuted(account.resolveThreadRoot(target)) ||
|
||||
isMutedPublicChatMessage(target.event, account.settings.mutedPublicChats.value)
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
when (event) {
|
||||
@@ -315,6 +325,12 @@ class EventNotificationConsumer(
|
||||
event: ChannelMessageEvent,
|
||||
account: Account,
|
||||
) {
|
||||
// Reads local device state, NOT the NIP-78 blob: on a push-driven cold start
|
||||
// AppSpecificState may not have decrypted yet (and for a NIP-55 account that is
|
||||
// an Amber IPC round-trip that can fail outright in the background). Losing this
|
||||
// race would post exactly the notification the mute exists to prevent.
|
||||
if (isMutedPublicChatMessage(event, account.settings.mutedPublicChats.value)) return
|
||||
|
||||
val note = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
|
||||
if (NotificationFeedFilter.isNotifiablePublicChatReply(note, account.signer.pubKey)) {
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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.priority
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Process
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Demotes the app's network/ingest worker threads below the UI thread so a cold-start relay storm
|
||||
* cannot starve the main thread out of its frames.
|
||||
*
|
||||
* **Why this exists.** On a cold start the outbox model dials ~190 relays at once and the process
|
||||
* grows to ~650 threads (OkHttp's TaskRunner pool, OkHttp dispatchers, the kotlinx scheduler and
|
||||
* Arti's tokio workers). Every one of them is born at nice 0. The main thread is nice -10, but a
|
||||
* single -10 thread against dozens of simultaneously-runnable nice-0 threads still loses a large
|
||||
* share of its schedulable time to the runqueue — long enough that the first feed frame takes
|
||||
* seconds and the "Loading account" screen stays on-screen well after the account itself has
|
||||
* loaded.
|
||||
*
|
||||
* **Measured on a release-codegen build** (`:amethyst:installPlayBenchmark`, i.e. R8-minified +
|
||||
* baseline-profile AOT), SM-T220, 5-round round-robin, every run valid:
|
||||
*
|
||||
* | workers at | starvation | runqueue wait | time to first paint | spread |
|
||||
* |---|---|---|---|---|
|
||||
* | nice 0 (off) | 26.7% | 2300 ms | 11.1 s | 5.63 s |
|
||||
* | nice 5 | 22.0% | 1774 ms | 8.0 s | 4.05 s |
|
||||
* | nice 9 | 17.1% | 1280 ms | 8.4 s | 3.05 s |
|
||||
* | **nice 10** | **14.6%** | **1234 ms** | **6.2 s** | **1.55 s** |
|
||||
*
|
||||
* nice 10 beat the control in 5 of 5 paired rounds (median 5.0 s faster, ~45%) and collapsed the
|
||||
* run-to-run spread from 5.6 s to 1.6 s, so [DEFAULT_NICE] is 10.
|
||||
*
|
||||
* **This effect only exists in a release build, so never re-validate it on a debug one.** In a
|
||||
* debug build the same sweep changes nothing measurable: there the main thread is ~70% busy,
|
||||
* saturated with ART interpretation, so scheduling was never the constraint (starvation is 15% in
|
||||
* debug vs 27% in release). R8 collapses main's own work while leaving the relay storm untouched,
|
||||
* which is what promotes starvation to the binding constraint. An emulator is equally misleading
|
||||
* for the opposite reason — its shared cores manufacture contention real hardware does not have.
|
||||
*
|
||||
* **Why a /proc sweep instead of thread factories.** The largest pool by far is OkHttp's
|
||||
* `TaskRunner` backend, a process-wide singleton whose thread factory OkHttp does not expose per
|
||||
* client, so there is no injection point to set a priority at creation time. Sweeping
|
||||
* `/proc/self/task` catches every pool uniformly — including threads OkHttp renames after the host
|
||||
* they are currently serving. A nice value is per-OS-thread and survives renaming, so seeing a
|
||||
* thread once is enough; the sweep only has to be frequent enough to catch newly-spawned ones.
|
||||
*
|
||||
* Note that [Thread.setPriority] does NOT map to a Linux nice level on Android — only
|
||||
* [Process.setThreadPriority] does. (`AudioTrackPlayer` documents the same trap for audio.)
|
||||
*
|
||||
* **Scope.** [DENYLIST] holds the threads that must keep their scheduling: the main thread,
|
||||
* RenderThread (it draws the frames we are trying to protect), the ART daemons (demoting
|
||||
* HeapTaskDaemon would make the GC pressure *worse*, not better) and binder threads (IPC replies
|
||||
* the system waits on). Everything else is app work that should yield to the UI.
|
||||
*
|
||||
* **Cost.** Each thread is touched once, not once per sweep, and the interval backs off whenever a
|
||||
* sweep finds nothing new — the storm front-loads thread creation, so most sweeps after the first
|
||||
* few seconds are empty. Threads that exit are pruned so a recycled tid is re-evaluated.
|
||||
*
|
||||
* **Runtime override.** [SETTING_KEY] overrides [DEFAULT_NICE] without a rebuild, and is also the
|
||||
* off switch (any value <= 0 disables the governor entirely):
|
||||
* ```
|
||||
* adb shell settings put global amethyst_worker_nice 5 # demote to nice 5 instead
|
||||
* adb shell settings put global amethyst_worker_nice 0 # disable
|
||||
* adb shell settings delete global amethyst_worker_nice # back to DEFAULT_NICE
|
||||
* ```
|
||||
* Despite AOSP's `androidSetThreadPriority` calling `set_sched_policy(SP_BACKGROUND)` at nice >= 10,
|
||||
* no cpuset/schedtune move was observed on real hardware (SM-T220 / Android 14): at nice 5, 9 and 10
|
||||
* every worker kept main's exact membership (`schedtune:/top-app`, `cpuset:/top-app`, `cpu:/`) and
|
||||
* only the nice value changed — so 10 carries no hidden cgroup penalty over 9.
|
||||
*/
|
||||
object WorkerThreadPriorityGovernor {
|
||||
/** `Settings.Global` key overriding [DEFAULT_NICE]; any value <= 0 disables the governor. */
|
||||
const val SETTING_KEY = "amethyst_worker_nice"
|
||||
|
||||
/** Best measured value on a release-codegen build — see the table in the class doc. */
|
||||
const val DEFAULT_NICE = 10
|
||||
|
||||
/** Sweep cadence while threads are still appearing. */
|
||||
private const val MIN_INTERVAL_MS = 250L
|
||||
|
||||
/** Ceiling the interval backs off to while a sweep keeps finding nothing new. */
|
||||
private const val MAX_BURST_INTERVAL_MS = 2_000L
|
||||
|
||||
/** How long to stay in the adaptive burst before settling at [IDLE_INTERVAL_MS]. */
|
||||
private const val BURST_DURATION_MS = 120_000L
|
||||
|
||||
/** Steady-state cadence; relay reconnects still spawn threads long after boot. */
|
||||
private const val IDLE_INTERVAL_MS = 5_000L
|
||||
|
||||
/**
|
||||
* Threads whose scheduling must not be touched. Matched as prefixes against the kernel `comm`
|
||||
* (which the kernel caps at 15 characters, so these are deliberately short).
|
||||
*/
|
||||
private val DENYLIST =
|
||||
listOf(
|
||||
// Draws the frames this whole exercise is meant to protect.
|
||||
"RenderThread",
|
||||
"hwuiTask",
|
||||
"GPU completion",
|
||||
// ART daemons — demoting the GC would deepen the very stalls we are fixing.
|
||||
"HeapTaskDaemon",
|
||||
"ReferenceQueueD",
|
||||
"FinalizerDaemon",
|
||||
"FinalizerWatchd",
|
||||
"Signal Catcher",
|
||||
"Jit thread pool",
|
||||
"Runtime worker",
|
||||
"perfetto_hprof",
|
||||
// Debugger/profiler plumbing.
|
||||
"ADB-JDWP",
|
||||
"JDWP",
|
||||
// Synchronous IPC the system framework blocks on.
|
||||
"binder:",
|
||||
)
|
||||
|
||||
@Volatile private var started = false
|
||||
|
||||
fun start(context: Context) {
|
||||
if (started) return
|
||||
val targetNice = resolveTargetNice(context)
|
||||
if (targetNice == null) {
|
||||
Log.i("ThreadPriority") { "Worker thread governor disabled via $SETTING_KEY" }
|
||||
return
|
||||
}
|
||||
started = true
|
||||
Log.i("ThreadPriority") { "Worker thread governor on, target nice=$targetNice" }
|
||||
|
||||
Thread({ sweepLoop(targetNice) }, "worker-nice-governor")
|
||||
.apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the nice level to apply, or null when the governor should not run at all. */
|
||||
private fun resolveTargetNice(context: Context): Int? {
|
||||
val configured =
|
||||
runCatching {
|
||||
Settings.Global.getInt(context.contentResolver, SETTING_KEY, DEFAULT_NICE)
|
||||
}.getOrDefault(DEFAULT_NICE)
|
||||
|
||||
// Only a demotion makes sense here; <= 0 is the documented off switch and anything above
|
||||
// the nice ceiling is a typo we should not act on.
|
||||
return configured.takeIf { it in 1..19 }
|
||||
}
|
||||
|
||||
private fun sweepLoop(targetNice: Int) {
|
||||
// The governor must keep running while the pools it polices saturate the CPU, so it runs
|
||||
// slightly above default rather than as background work.
|
||||
runCatching { Process.setThreadPriority(Process.THREAD_PRIORITY_FOREGROUND) }
|
||||
|
||||
val startedAt = SystemClock.elapsedRealtime()
|
||||
val mainTid = Process.myPid()
|
||||
// Tids already dealt with — demoted, or skipped because they are on the denylist. Both are
|
||||
// permanent decisions, so keeping them here means a thread costs one `comm` read for its
|
||||
// whole life instead of one per sweep. Seeded with our own tid so the sweep can't demote
|
||||
// the governor itself.
|
||||
val handled = HashSet<Int>().apply { add(Process.myTid()) }
|
||||
var interval = MIN_INTERVAL_MS
|
||||
|
||||
while (true) {
|
||||
val demoted = sweepOnce(mainTid, targetNice, handled)
|
||||
if (demoted > 0) {
|
||||
Log.d("ThreadPriority") { "Demoted $demoted thread(s) to nice $targetNice" }
|
||||
}
|
||||
|
||||
// Thread creation is front-loaded into the connect storm, so once a sweep comes back
|
||||
// empty the next one almost certainly will too — back off instead of spinning.
|
||||
interval =
|
||||
when {
|
||||
SystemClock.elapsedRealtime() - startedAt >= BURST_DURATION_MS -> IDLE_INTERVAL_MS
|
||||
demoted > 0 -> MIN_INTERVAL_MS
|
||||
else -> (interval * 2).coerceAtMost(MAX_BURST_INTERVAL_MS)
|
||||
}
|
||||
|
||||
runCatching { Thread.sleep(interval) }.onFailure { return }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sweepOnce(
|
||||
mainTid: Int,
|
||||
targetNice: Int,
|
||||
handled: MutableSet<Int>,
|
||||
): Int {
|
||||
// list() rather than listFiles(): this runs hundreds of times over a boot and the File
|
||||
// objects would be pure garbage on an already GC-pressured heap.
|
||||
val tidNames = File("/proc/self/task").list() ?: return 0
|
||||
val live = HashSet<Int>(tidNames.size * 2)
|
||||
var demoted = 0
|
||||
|
||||
for (tidName in tidNames) {
|
||||
val tid = tidName.toIntOrNull() ?: continue
|
||||
live.add(tid)
|
||||
if (tid == mainTid || tid in handled) continue
|
||||
|
||||
// A thread can exit between listing and reading; treat any failure as "skip" and let
|
||||
// the next sweep retry, since it is not yet recorded in `handled`.
|
||||
val name =
|
||||
runCatching {
|
||||
File("/proc/self/task/$tidName/comm").readText().trim()
|
||||
}.getOrNull() ?: continue
|
||||
|
||||
if (DENYLIST.any { name.startsWith(it) }) {
|
||||
handled.add(tid)
|
||||
continue
|
||||
}
|
||||
|
||||
if (runCatching { Process.setThreadPriority(tid, targetNice) }.isSuccess) {
|
||||
handled.add(tid)
|
||||
demoted++
|
||||
}
|
||||
}
|
||||
|
||||
// Drop tids that have exited so the kernel recycling one into a new thread doesn't leave
|
||||
// that thread permanently un-demoted.
|
||||
handled.retainAll(live)
|
||||
return demoted
|
||||
}
|
||||
}
|
||||
+30
-21
@@ -21,9 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
@@ -103,6 +101,10 @@ class AuthCoordinator(
|
||||
// question. Returning early here instead made "decide per relay" mean "deny, and
|
||||
// don't mention it" for every purpose that names someone else — the exact case the
|
||||
// prompt was built to explain.
|
||||
//
|
||||
// It does not reach the "…I'm reading someone I follow" toggle at all: a follow's
|
||||
// outbox relay can never be first-party for us, so applying it there emptied the
|
||||
// category instead of narrowing it. RelayAuthResolver.customAllows has the detail.
|
||||
val firstParty = isFirstParty(account, relayUrl)
|
||||
|
||||
val approve =
|
||||
@@ -115,8 +117,9 @@ class AuthCoordinator(
|
||||
// reveal @b — an answer is only about the identity it was shown for.
|
||||
// The bus still collapses concurrent challenges for the same
|
||||
// (relay, account) pair, which is the case the shared prompt was for.
|
||||
// In practice this rarely means two dialogs: isFirstParty already
|
||||
// drops every account without its own reason to be on this relay.
|
||||
// In practice this rarely means two dialogs: for everything except
|
||||
// reading a follow, isFirstParty already drops every account without
|
||||
// its own reason to be on this relay.
|
||||
//
|
||||
// But never block the derived stream-key AUTH behind that dialog: on a
|
||||
// relay that hosts our Concord planes we DISMISS the user-auth ASK
|
||||
@@ -165,7 +168,7 @@ class AuthCoordinator(
|
||||
// Remember why we granted this relay so the settings screen can explain it.
|
||||
account.relayAuthLedger.recordGrant(context)
|
||||
try {
|
||||
signed.add(account.signer.sign(buzzAugmented(authTemplate, account.pubKey, relayUrl)))
|
||||
signed.add(account.signer.sign(buzzAugmented(authTemplate, account, relayUrl)))
|
||||
} catch (e: Exception) {
|
||||
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
|
||||
}
|
||||
@@ -207,23 +210,23 @@ class AuthCoordinator(
|
||||
}
|
||||
|
||||
/**
|
||||
* If [relayUrl] speaks the Buzz dialect and this device holds a NIP-OA attestation
|
||||
* authorizing [accountPubKey], returns [template] with the owner-signed `auth` tag
|
||||
* appended — so the relay grants virtual membership to an un-enrolled agent key while
|
||||
* its owner stays a member. Otherwise returns [template] unchanged.
|
||||
* If [relayUrl] speaks the Buzz dialect and [account] holds a NIP-OA attestation authorizing
|
||||
* its key, returns [template] with the owner-signed `auth` tag appended — so the relay grants
|
||||
* virtual membership to an un-enrolled agent key while its owner stays a member. Otherwise
|
||||
* returns [template] unchanged.
|
||||
*
|
||||
* Applied ONLY to an account's own AUTH (the caller passes the account pubkey), never
|
||||
* to the Concord stream-key AUTHs that share the same [template] object, and it is a
|
||||
* no-op on non-Buzz relays and for accounts with no held attestation — so it can never
|
||||
* add an `auth` tag where one isn't wanted.
|
||||
* Applied ONLY to an account's own AUTH (the caller passes the account being signed for), never
|
||||
* to the Concord stream-key AUTHs that share the same [template] object, and it is a no-op on
|
||||
* non-Buzz relays and for accounts with no held attestation — so it can never add an `auth` tag
|
||||
* where one isn't wanted.
|
||||
*/
|
||||
private fun buzzAugmented(
|
||||
template: EventTemplate<RelayAuthEvent>,
|
||||
accountPubKey: HexKey,
|
||||
account: Account,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
): EventTemplate<RelayAuthEvent> {
|
||||
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
|
||||
val authTag = BuzzHeldAttestations.authTagFor(accountPubKey) ?: return template
|
||||
val authTag = account.buzzAttestation.authTag() ?: return template
|
||||
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
|
||||
}
|
||||
|
||||
@@ -238,18 +241,24 @@ class AuthCoordinator(
|
||||
* Merely *following* the counterparty of someone else's traffic is deliberately NOT first-party:
|
||||
* that is exactly how a bystander account got dragged into a paid inbox relay's AUTH (the shared
|
||||
* auth context carries the OTHER account's counterparties, evaluated against this account's
|
||||
* follow graph). Reads of a followed author's outbox on an auth-gated relay this account doesn't
|
||||
* use are therefore no longer auto-authed — a deliberate privacy-positive trade-off.
|
||||
* follow graph).
|
||||
*
|
||||
* Reading a followed author's outbox is the one case this cannot speak to. That relay is the
|
||||
* author's, so nothing here can ever return true for it, which is why
|
||||
* [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthResolver] applies the
|
||||
* [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles.readFollows] category
|
||||
* without consulting this — otherwise the toggle would be permanently off.
|
||||
*/
|
||||
private fun isFirstParty(
|
||||
account: Account,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
): Boolean =
|
||||
// A Buzz workspace the user explicitly joined is a first-party reason to authenticate: its
|
||||
// channel/DM discovery is read-only (`#p` = me), which is otherwise deliberately NOT
|
||||
// A Buzz workspace THIS account explicitly joined is a first-party reason to authenticate:
|
||||
// its channel/DM discovery is read-only (`#p` = me), which is otherwise deliberately NOT
|
||||
// first-party, so without this the p-gated 44100/30622 reads would never be served and the
|
||||
// workspace would stay empty.
|
||||
BuzzWorkspaces.isJoined(relayUrl) ||
|
||||
// workspace would stay empty. Read off the account, never a device-global set: the invite was
|
||||
// redeemed by one key, and a shared set made every other logged-in account first-party here.
|
||||
account.buzzWorkspaces.isJoined(relayUrl) ||
|
||||
RelayAuthFirstParty.hasReason(
|
||||
me = account.pubKey,
|
||||
relayUrl = relayUrl,
|
||||
|
||||
+12
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.buzz.BuzzMembershipEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
|
||||
@@ -33,6 +34,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47Wa
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletHistoryEoseManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
@@ -96,6 +98,11 @@ class AccountFilterAssembler(
|
||||
// History: older notifications, paged backward by until+limit per relay, driven by the feed's markers.
|
||||
val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::preferredKeys)
|
||||
|
||||
// History: older NIP-60 spending rows (kind:7376), paged backward by until+limit per outbox relay,
|
||||
// driven by the wallet's transaction list. The live wallet subscription below reads six kinds in one
|
||||
// uncapped REQ, so history — the most numerous of them — is exactly what a relay's cap truncates.
|
||||
val cashuWalletHistory = CashuWalletHistoryEoseManager(client, ::preferredKeys)
|
||||
|
||||
val group =
|
||||
listOf(
|
||||
AccountMetadataEoseManager(client, ::preferredKeys),
|
||||
@@ -109,7 +116,12 @@ class AccountFilterAssembler(
|
||||
// NIP-60 wallet + NIP-61 nutzap inbox. Mounted here rather than run from a collector
|
||||
// inside CashuWalletState, so it starts and stops with every other account-level loader.
|
||||
CashuWalletEoseManager(client, ::preferredKeys),
|
||||
cashuWalletHistory,
|
||||
MarmotGroupEventsEoseManager(client, ::preferredKeys),
|
||||
// What a Buzz workspace relay addresses to me personally: membership verdicts (44100/44101)
|
||||
// and my hidden-DM snapshot (30622). Feeds both the channel-invite prompts and Buzz DM
|
||||
// discovery, which read them back out of LocalCache rather than each opening a `#p=me` REQ.
|
||||
BuzzMembershipEoseManager(client, ::preferredKeys),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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.relayClient.reqCommand.account.buzz
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** The relay's membership verdicts addressed to me: 44100 "you were added", 44101 "you were removed". */
|
||||
val MembershipNotificationKinds =
|
||||
listOf(
|
||||
MemberAddedNotificationEvent.KIND,
|
||||
MemberRemovedNotificationEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* Everything the workspace relay addresses to me personally: the membership verdicts plus the kind-30622
|
||||
* snapshot of which DMs I have hidden. One filter class — `#p` = me, channel-less, workspace relay — so
|
||||
* they share a subscription.
|
||||
*/
|
||||
private val WorkspaceInboxKinds = MembershipNotificationKinds + DmVisibilityEvent.KIND
|
||||
|
||||
/**
|
||||
* One workspace relay's worth of "things addressed to me personally": membership verdicts and my
|
||||
* hidden-DM snapshot, `#p` = me.
|
||||
*
|
||||
* Empty for a missing pubkey so a not-yet-loaded account asks for nothing rather than for everyone's.
|
||||
*/
|
||||
fun filterWorkspaceInboxToPubkey(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey?,
|
||||
since: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (pubkey.isNullOrEmpty()) return emptyList()
|
||||
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
ExplainedFilter(
|
||||
purpose = SubPurpose.NOTIFICATIONS,
|
||||
accountPubKeys = listOf(pubkey),
|
||||
kinds = WorkspaceInboxKinds,
|
||||
tags = mapOf("p" to listOf(pubkey)),
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-on read of the Buzz relay's membership notifications addressed to me (`#p` = me) across every
|
||||
* joined workspace — the events behind the "somebody added you to a channel" prompts and behind Buzz DM
|
||||
* discovery.
|
||||
*
|
||||
* On a Buzz relay membership is server-side: another member issues the add, the relay writes me into the
|
||||
* channel's kind-39002 roster, and then addresses me a kind-44100 naming the actor. There is no queryable
|
||||
* channel list, so this `#p`-gated stream *is* the enumeration; the matching kind-44101 withdraws one.
|
||||
*
|
||||
* ### Why its own subscription
|
||||
*
|
||||
* This filter is channel-less by nature — it is the query that *discovers* which channels exist for me,
|
||||
* so there is no `#h` to scope it with. `block/buzz` downgrades any subscription carrying a channel-less
|
||||
* (or multi-channel) filter to "global", a class that by design never receives channel-scoped events;
|
||||
* that is exactly why NIP-29 group activity was moved out of
|
||||
* [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager]
|
||||
* and onto the per-channel tails. Global is the correct class for 44100/44101 — they are stored globally
|
||||
* so their target can find them — but only as long as nothing channel-scoped shares the subscription. So
|
||||
* these ride a manager of their own rather than joining the inbox notification filters.
|
||||
*
|
||||
* Also note the relays: workspace relays are not the account's `notificationRelays`, so the inbox manager
|
||||
* would not query them even if the filter class allowed it.
|
||||
*
|
||||
* ### Auth
|
||||
*
|
||||
* The read is `#p`-gated, so the relay answers `auth-required:`. A joined workspace is first-party, so
|
||||
* each one is pre-approved on the account's auth ledger here — the restore-from-disk path doesn't set
|
||||
* that, unlike the invite/import/console entry points. [com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator]
|
||||
* re-signs on the refusal using the connection's stored challenge and the OK re-drives the REQ, so the
|
||||
* subscription recovers on its own rather than needing a warm-auth one-shot fetch.
|
||||
*/
|
||||
class BuzzMembershipEoseManager(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<AccountQueryState>,
|
||||
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
|
||||
override fun user(key: AccountQueryState) = key.account.userProfile()
|
||||
|
||||
override fun updateFilter(
|
||||
key: AccountQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val me = key.account.userProfile().pubkeyHex
|
||||
|
||||
// No `since` floor on a cold start. LocalCache is in-memory, so a relaunch has to re-read the
|
||||
// whole membership history to know which channels I am in — and the EOSE map that would supply
|
||||
// a floor is in-memory too, so it is null exactly when the full read is needed. The filter is
|
||||
// `#p`-scoped to my own key, so an all-time query costs one index scan.
|
||||
return key.account.buzzWorkspaces.flow.value.flatMap { relay ->
|
||||
filterWorkspaceInboxToPubkey(relay, me, since?.get(relay)?.time)
|
||||
}
|
||||
}
|
||||
|
||||
private val userJobMap = mutableMapOf<User, List<Job>>()
|
||||
|
||||
override fun newSub(key: AccountQueryState): Subscription {
|
||||
val user = user(key)
|
||||
userJobMap[user]?.forEach { it.cancel() }
|
||||
|
||||
userJobMap[user] =
|
||||
listOf(
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.account.buzzWorkspaces.flow.collectLatest { relays ->
|
||||
// Idempotent, and re-run per joined set rather than once at mount so a workspace
|
||||
// joined later is pre-approved too.
|
||||
relays.forEach { key.account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return super.newSub(key)
|
||||
}
|
||||
|
||||
override fun endSub(
|
||||
key: User,
|
||||
subId: String,
|
||||
) {
|
||||
super.endSub(key, subId)
|
||||
userJobMap.remove(key)?.forEach { it.cancel() }
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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.relayClient.reqCommand.account.nip60Cashu
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Loads the account's NIP-60 spending **history** (kind:7376) by **`until`+`limit` paging, per relay, on
|
||||
* demand**, so the wallet's transaction list can be scrolled back through a wallet's whole lifetime
|
||||
* instead of showing whatever suffix one uncapped REQ happened to return.
|
||||
*
|
||||
* ### Why history needs its own loader
|
||||
*
|
||||
* [CashuWalletEoseManager] opens one live subscription per outbox relay covering six kinds at once, with
|
||||
* no `limit`. A relay answers that with its own cap applied to the newest matching events, and history
|
||||
* rows are the most numerous kind in the query — so on a wallet with a few hundred transactions the list
|
||||
* is truncated to a recent-N view that differs per device, and no later REQ ever asks for the rest (the
|
||||
* EOSE moves `since` forward). That is the same truncation that was silently costing balance, except
|
||||
* here the fix is paging rather than a one-shot walk: history is display-only and unbounded, so pulling
|
||||
* all of it at launch would be a large download for something the user may never scroll.
|
||||
*
|
||||
* ### How it pages
|
||||
*
|
||||
* There is no proactive walk. Each relay advances exactly one page when the transaction list asks
|
||||
* ([advance] / [advanceAll]), then **parks** — a relay that finished a page keeps the same `until` in
|
||||
* [updateFilter], so re-assembly triggered by *another* relay advancing does not re-REQ it. The list is
|
||||
* the driver: it pulls a page on open and another whenever the user scrolls near the end, so nothing is
|
||||
* fetched while the wallet is off screen.
|
||||
*
|
||||
* Paged over the account's **outbox** relays — the same set the wallet publishes its own events to, and
|
||||
* so the same set [CashuWalletEoseManager] reads its own kinds back from.
|
||||
*
|
||||
* ### Floor: no live tail
|
||||
*
|
||||
* The DM/notification pagers floor at `now − liveTail` because a separate live loader is known to cover
|
||||
* everything newer. The wallet has no such guarantee: its live REQ carries no `since` and no `limit`, so
|
||||
* how far back it actually reaches is whatever the relay decided — which is the very thing being fixed
|
||||
* here. Flooring at a fixed tail would therefore leave a gap between the relay's cap and the tail
|
||||
* boundary that neither loader ever asks for. So this pager floors at **now** and overlaps the live
|
||||
* subscription completely; duplicates cost nothing (both `LocalCache` and `CashuWalletState.historyEvents`
|
||||
* are keyed by event id) and gaplessness is worth more than the overlap.
|
||||
*
|
||||
* The per-relay cursors live on the [Account] (so they share the account's lifetime); this class binds
|
||||
* the single-active [BackwardRelayPager] to them on [newSub], builds the REQ filters, and forwards relay
|
||||
* callbacks into the pager. A relay is *done* once it answers an empty page; one that will not answer
|
||||
* (auth CLOSE, unreachable, silent) is flagged *stalled* but kept, and [PagingStatus.exhausted] flips
|
||||
* once every relay is done or stalled — callers rendering a terminal state should split on
|
||||
* [PagingStatus.stalledCount].
|
||||
*/
|
||||
class CashuWalletHistoryEoseManager(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<AccountQueryState>,
|
||||
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
|
||||
override fun user(key: AccountQueryState) = key.account.userProfile()
|
||||
|
||||
// liveTailSeconds = 0 pins the floor at `now` — see the class doc on why the wallet, unlike DMs,
|
||||
// cannot assume a live loader already covers a recent window.
|
||||
private val pager = BackwardRelayPager("cashu.history", pageLimit = PAGE_LIMIT, liveTailSeconds = 0L)
|
||||
|
||||
val loadingMore: StateFlow<Boolean> = pager.loadingMore
|
||||
val status: StateFlow<PagingStatus> = pager.status
|
||||
|
||||
/** The relays this account pages its own NIP-60 history back through: where it publishes. */
|
||||
private fun historyRelaySet(account: Account): Set<NormalizedRelayUrl> = account.outboxRelays.flow.value
|
||||
|
||||
override fun updateFilter(
|
||||
key: AccountQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val pubkey = user(key).pubkeyHex
|
||||
val relays = historyRelaySet(key.account)
|
||||
|
||||
// Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished
|
||||
// a page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances)
|
||||
// doesn't re-REQ it — it stays parked until the list advances it again.
|
||||
val armed = pager.armedRelays(relays)
|
||||
if (armed.isEmpty()) return emptyList()
|
||||
|
||||
return armed.flatMap { relay ->
|
||||
val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList()
|
||||
Log.d(TAG) { "[cashu.history] REQ ${relay.url} until=$until limit=${pager.pageLimit}" }
|
||||
filterCashuHistoryToPubkey(relay, pubkey, until, pager.pageLimit)
|
||||
}
|
||||
}
|
||||
|
||||
/** Steps a single [relay] to its next, older page. */
|
||||
fun advance(relay: NormalizedRelayUrl) {
|
||||
if (pager.advance(relay)) invalidateFilters()
|
||||
}
|
||||
|
||||
/** Steps every not-done, not-in-flight relay one page. What the transaction list drives. */
|
||||
fun advanceAll() {
|
||||
if (pager.advanceAll()) {
|
||||
Log.d(TAG) { "[cashu.history] advanceAll" }
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
private val userJobMap = mutableMapOf<User, List<Job>>()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override fun newSub(key: AccountQueryState): Subscription {
|
||||
// Repoint the single-active orchestrator at this account's cashu-history cursors and the relay
|
||||
// set it fans out to, refreshing the display flows from the restored progress.
|
||||
pager.bind(key.account.cashuHistory, key.account.scope) { historyRelaySet(key.account) }
|
||||
|
||||
val user = user(key)
|
||||
userJobMap[user]?.forEach { it.cancel() }
|
||||
userJobMap[user] =
|
||||
listOf(
|
||||
// A relay joining/leaving the outbox set re-issues the REQ so a newly-added relay can be
|
||||
// armed and a removed one drops out. Sampled — a relay-list edit lands as a burst.
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.account.outboxRelays.flow
|
||||
.sample(1000)
|
||||
.collectLatest { invalidateFilters() }
|
||||
},
|
||||
)
|
||||
|
||||
return requestNewSubscription(historyListener(key))
|
||||
}
|
||||
|
||||
private fun historyListener(key: AccountQueryState): SubscriptionListener {
|
||||
// A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to
|
||||
// another account; gate the pager (single-active) on whether it's still bound to THIS account's
|
||||
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
|
||||
val myCursors = key.account.cashuHistory
|
||||
return object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (pager.isBoundTo(myCursors) && pager.onEose(relay)) {
|
||||
Log.d(TAG) { "[cashu.history] ${relay.url} reached the bottom (done)" }
|
||||
}
|
||||
// No auto-advance: the relay parks here until the transaction list asks for another page.
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
message: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: NormalizedRelayUrl,
|
||||
message: String,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun endSub(
|
||||
key: User,
|
||||
subId: String,
|
||||
) {
|
||||
super.endSub(key, subId)
|
||||
userJobMap[key]?.forEach { it.cancel() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CashuPagination"
|
||||
|
||||
/**
|
||||
* Rows pulled per relay per advance. Smaller than the notification pager's 500: every kind:7376
|
||||
* row costs a NIP-44 decrypt to render, which on an external signer is an out-of-process
|
||||
* round-trip, so a page is sized to fill a screen or two rather than to fill memory.
|
||||
*/
|
||||
const val PAGE_LIMIT = 100
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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.relayClient.reqCommand.account.nip60Cashu
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
|
||||
/**
|
||||
* One backward-paging page of the account's NIP-60 spending history (kind:7376) on one of its outbox
|
||||
* relays: my own history rows, strictly older than [until], newest-first, capped at [limit].
|
||||
*
|
||||
* Deliberately kind:7376 only. The proofs (kind:7375) are not paged on demand — a balance computed from
|
||||
* a partial proof set is simply wrong, so those are walked to exhaustion in one shot by
|
||||
* `CashuWalletState.resyncProofsFromRelays`. History is the opposite: it is display-only, unbounded in
|
||||
* length, and the user reads it newest-first, so it is exactly the shape `until`+`limit` paging is for.
|
||||
*
|
||||
* `until`+`limit` rather than a `since`/`until` window for the reason in `RelayLoadingCursors`: an empty
|
||||
* time slice cannot distinguish "nothing older here" from "a quiet month", whereas an empty
|
||||
* `until`+`limit` page is gap-proof proof of the bottom.
|
||||
*/
|
||||
fun filterCashuHistoryToPubkey(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey?,
|
||||
until: Long,
|
||||
limit: Int,
|
||||
): List<RelayBasedFilter> {
|
||||
if (pubkey.isNullOrEmpty()) return emptyList()
|
||||
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
ExplainedFilter(
|
||||
purpose = SubPurpose.WALLET,
|
||||
accountPubKeys = listOfNotNull(pubkey),
|
||||
kinds = listOf(CashuSpendingHistoryEvent.KIND),
|
||||
authors = listOf(pubkey),
|
||||
limit = limit,
|
||||
until = until,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -151,21 +151,16 @@ fun painterRes(
|
||||
@DrawableRes resourceId: Int,
|
||||
sizeReference: Int,
|
||||
): Painter {
|
||||
val cached = iconCache.get(resourceId)
|
||||
if (cached != null) {
|
||||
val composition = cached.get(sizeReference)
|
||||
if (composition != null) {
|
||||
return composition
|
||||
}
|
||||
}
|
||||
val bySize = iconCache.get(resourceId)
|
||||
bySize?.get(sizeReference)?.let { return it }
|
||||
|
||||
val loaded = painterResource(resourceId)
|
||||
|
||||
if (cached == null) {
|
||||
iconCache.put(resourceId, LruCache<Int, Painter>(10))
|
||||
} else {
|
||||
cached.put(sizeReference, loaded)
|
||||
}
|
||||
// Store on the FIRST miss as well. This previously created the per-size cache but never
|
||||
// put `loaded` into it, so a resource had to be requested three times before it could
|
||||
// ever hit: once to install the (empty) inner cache, once to populate it, once to read it.
|
||||
val sizes = bySize ?: LruCache<Int, Painter>(10).also { iconCache.put(resourceId, it) }
|
||||
sizes.put(sizeReference, loaded)
|
||||
|
||||
return loaded
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInviteCard
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
val DefaultFeedOrder: Comparator<Note> =
|
||||
@@ -33,6 +34,20 @@ val DefaultFeedOrderEvent: Comparator<Event> =
|
||||
val DefaultFeedOrderCard: Comparator<Card> =
|
||||
compareByDescending<Card> { it.createdAt() }.thenBy { it.id() }
|
||||
|
||||
/**
|
||||
* Notifications order: unanswered channel invites first, then newest-first like everything else.
|
||||
*
|
||||
* An invite is a standing question — it stays actionable until it is answered — so ranking it purely by
|
||||
* `created_at` would let a week of reactions bury a decision the user still has to make, and a long
|
||||
* enough feed could page it off the end entirely. Everything else on the tab is a dated event and keeps
|
||||
* the ordinary ordering. Pending is the only state that reaches a card: answering one drops it from the
|
||||
* projection, so it never lingers at the top.
|
||||
*/
|
||||
val NotificationFeedOrderCard: Comparator<Card> =
|
||||
compareBy<Card> { if (it is ChannelInviteCard) 0 else 1 }
|
||||
.thenByDescending { it.createdAt() }
|
||||
.thenBy { it.id() }
|
||||
|
||||
// Snapshots createdAt once per note so the comparator stays consistent even if
|
||||
// another thread swaps a Note's event mid-sort (e.g. a newer AddressableEvent
|
||||
// arriving from a relay). Avoids TimSort's "Comparison method violates its
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
|
||||
@@ -117,6 +118,15 @@ fun routeFor(
|
||||
return routeFor(relayGroup)
|
||||
}
|
||||
|
||||
// A channel invite (kind-44100) has two destinations and gives each its own target: the room block
|
||||
// inside the card carries its own click and opens the room, so the rest of the row — the header, the
|
||||
// body around the block, the space below the author — opens the reply page instead. Replying is the
|
||||
// one thing you can do with an invite that the card itself doesn't already offer a button for, and
|
||||
// the generic thread view has nothing to show for a relay-signed notification nobody replied to yet.
|
||||
if (note.event is MemberAddedNotificationEvent) {
|
||||
return Route.GenericCommentPost(replyTo = note.idHex)
|
||||
}
|
||||
|
||||
// Concord channel content (kind 9 chat, 1111 reply, 7 reaction) lands in LocalCache as a real
|
||||
// Note attached to its ConcordChannel gatherer. Route to the Concord chat instead of the generic
|
||||
// thread view it would otherwise fall through to: a minichat reply (kind-1111) opens its thread
|
||||
|
||||
@@ -135,6 +135,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelInvite
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChat
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
|
||||
@@ -231,6 +232,7 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
@@ -1080,6 +1082,10 @@ private fun RenderNoteRow(
|
||||
RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is MemberAddedNotificationEvent -> {
|
||||
RenderChannelInvite(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is BadgeDefinitionEvent -> {
|
||||
BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.theme.SmallestBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.isLight
|
||||
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
|
||||
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -369,22 +370,29 @@ fun CardBody(
|
||||
VerticalDivider(color = primaryLight)
|
||||
|
||||
val isMuted = accountViewModel.isThreadMutedFor(note)
|
||||
NoteQuickActionItem(
|
||||
MaterialSymbols.AutoMirrored.VolumeOff,
|
||||
stringRes(
|
||||
// Every NIP-28 message shares one thread root — the channel — so "Mute thread" on a
|
||||
// public chat can only ever hide that channel's ENTIRE content, and invisibly: the
|
||||
// Messages row has no such check, so the room still lists while its messages vanish.
|
||||
// "Mute notifications" owns silencing a public chat now. Unmute stays reachable so
|
||||
// anyone already caught by a legacy mute can escape from the message in front of them.
|
||||
if (note.event !is ChannelMessageEvent || isMuted) {
|
||||
NoteQuickActionItem(
|
||||
MaterialSymbols.AutoMirrored.VolumeOff,
|
||||
stringRes(
|
||||
if (isMuted) {
|
||||
R.string.quick_action_unmute_thread
|
||||
} else {
|
||||
R.string.quick_action_mute_thread
|
||||
},
|
||||
),
|
||||
) {
|
||||
if (isMuted) {
|
||||
R.string.quick_action_unmute_thread
|
||||
accountViewModel.unmuteThread(note)
|
||||
} else {
|
||||
R.string.quick_action_mute_thread
|
||||
},
|
||||
),
|
||||
) {
|
||||
if (isMuted) {
|
||||
accountViewModel.unmuteThread(note)
|
||||
} else {
|
||||
accountViewModel.muteThread(note)
|
||||
accountViewModel.muteThread(note)
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-13
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -352,19 +353,26 @@ fun noteActionSections(
|
||||
val moderation =
|
||||
buildList {
|
||||
val isThreadMuted = accountViewModel.isThreadMutedFor(note)
|
||||
add(
|
||||
NoteAction(
|
||||
MaterialSymbols.AutoMirrored.VolumeOff,
|
||||
stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread),
|
||||
) {
|
||||
if (isThreadMuted) {
|
||||
accountViewModel.unmuteThread(note)
|
||||
} else {
|
||||
accountViewModel.muteThread(note)
|
||||
}
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
// Every NIP-28 message shares one thread root — the channel — so "Mute thread" on a
|
||||
// public chat can only ever hide that channel's ENTIRE content, and invisibly: the
|
||||
// Messages row has no such check, so the room still lists while its messages vanish.
|
||||
// "Mute notifications" owns silencing a public chat now. Unmute stays reachable so
|
||||
// anyone already caught by a legacy mute can escape from the message in front of them.
|
||||
if (note.event !is ChannelMessageEvent || isThreadMuted) {
|
||||
add(
|
||||
NoteAction(
|
||||
MaterialSymbols.AutoMirrored.VolumeOff,
|
||||
stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread),
|
||||
) {
|
||||
if (isThreadMuted) {
|
||||
accountViewModel.unmuteThread(note)
|
||||
} else {
|
||||
accountViewModel.muteThread(note)
|
||||
}
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Own messages always get a delete affordance (the surface routes private
|
||||
// rumors through the gift-wrapped deletion); reporting yourself never
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
* 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.note.types
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
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.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.buzz.toMembershipNotice
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ObserveAndDrawInnerUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
private val BannerHeight = 84.dp
|
||||
private val BlockShape = RoundedCornerShape(12.dp)
|
||||
private val RosterFaceSize = 19.dp
|
||||
|
||||
/**
|
||||
* The body of a "somebody added you to a channel" row (kind 44100).
|
||||
*
|
||||
* Only the body: the row itself is an ordinary [com.vitorpamplona.amethyst.ui.note.NoteCompose], so the
|
||||
* author header, overflow menu, reaction bar, last-read background and click-through all come from the
|
||||
* same code every other notification uses.
|
||||
*
|
||||
* ### Why the body carries the identity
|
||||
*
|
||||
* A kind-44100 is signed by the **relay keypair** — the relay is reporting a membership change it made,
|
||||
* so it really is the author. That has a visible consequence: `NoteCompose` draws its header from
|
||||
* `note.author`, a relay keypair has no kind-0 and no NIP-05, so line one falls through
|
||||
* `UsernameDisplay` to a bare `npub1…` and line two (`ObserveDisplayNip05Status`) renders nothing at
|
||||
* all. The header therefore identifies nobody a reader recognises, and everything that says what this
|
||||
* row is about has to live down here: who added you, and what they added you to.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderChannelInvite(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val workspaces = accountViewModel.account.buzzWorkspaces.flow.value
|
||||
val notice = remember(note, workspaces) { note.toMembershipNotice(workspaces) } ?: return
|
||||
// A withdrawn membership is not an invite. The projection already excludes these before a card is
|
||||
// built; re-checked here because this renderer is reachable from any NoteCompose over a 44100.
|
||||
if (notice.removed) return
|
||||
|
||||
val groupId = remember(notice) { GroupId(notice.channelId, notice.relay) }
|
||||
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
|
||||
|
||||
// Recompose in place as the relay-signed metadata and roster land, so name, picture, member count
|
||||
// and description fill in without the row being rebuilt. observeChannel also mounts the channel
|
||||
// subscription, which is what actually goes and fetches the kind-39000 for a group the viewer has
|
||||
// never opened — the common case for an invite.
|
||||
val channelState by observeChannel(baseChannel, accountViewModel)
|
||||
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val actorUser = remember(notice.actor) { notice.actor?.let { LocalCache.getOrCreateUser(it) } }
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
// Who did this. The header above is an npub belonging to the relay, so without this the row
|
||||
// never names a person — and the actor is the whole difference between "I joined this" and "a
|
||||
// stranger put me here".
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (actorUser != null) {
|
||||
UserPicture(actorUser, Size22dp, accountViewModel = accountViewModel, nav = nav)
|
||||
UsernameDisplay(actorUser, Modifier.weight(1f, fill = false), accountViewModel = accountViewModel)
|
||||
} else {
|
||||
Text(
|
||||
text = stringRes(R.string.channel_invite_unknown_actor),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringRes(R.string.channel_invite_added_you),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
ChannelBanner(channel, accountViewModel) {
|
||||
// Opening a group that is not on my kind-10009 yet is exactly what this route supports, so
|
||||
// the viewer can look at the channel before answering.
|
||||
nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url))
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
) {
|
||||
// Leave is separate from Ignore on purpose: Ignore is a local display choice that leaves you
|
||||
// in the roster, Leave is the kind-9022 that actually removes you from the channel. It is
|
||||
// also the destructive one, so it stays the lightest of the three.
|
||||
TextButton(onClick = { accountViewModel.leaveChannelInvite(channel) }) {
|
||||
Text(stringRes(R.string.channel_invite_leave), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
TextButton(onClick = { accountViewModel.dismissChannelInvite(notice.channelId) }) {
|
||||
Text(stringRes(R.string.channel_invite_ignore))
|
||||
}
|
||||
// Accepting *is* `addRelayGroupToMessages`, the same call behind the channel top bar's
|
||||
// "Add to Messages", so it carries that label rather than a second word for one action.
|
||||
//
|
||||
// Compact rather than a stock Button: the default 40dp height with 24dp of horizontal
|
||||
// padding is a call-to-action size, and on a row that already offers two other choices it
|
||||
// dominated them. This keeps the fill — Accept is unmistakably primary — at roughly the
|
||||
// optical height of the text buttons beside it.
|
||||
Button(
|
||||
onClick = { accountViewModel.acceptChannelInvite(channel) },
|
||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
||||
modifier = Modifier.height(34.dp),
|
||||
) {
|
||||
Text(stringRes(R.string.add_to_messages), fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel as a cover block: its picture edge to edge, name reversed out over a scrim, visibility
|
||||
* badge, then roster and description.
|
||||
*
|
||||
* The picture is drawn *over* a gradient rather than falling back to one, so a channel with no
|
||||
* `picture` — and one whose picture fails to load — both land on the same stable colour instead of an
|
||||
* empty band. No branch, no placeholder state.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChannelBanner(
|
||||
channel: RelayGroupChannel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val name = channel.toBestDisplayName()
|
||||
val picture = channel.profilePicture()
|
||||
val description = channel.summary()?.takeIf { it.isNotBlank() }
|
||||
val memberCount = channel.memberCount()
|
||||
val gradient = remember(channel.groupId.id) { identityGradient(channel.groupId.id) }
|
||||
|
||||
val badge =
|
||||
when {
|
||||
// Closed (invite-only) is the more actionable signal to somebody deciding whether to stay
|
||||
// than private is, so it wins when both are set.
|
||||
channel.isClosed() -> stringRes(R.string.relay_group_badge_invite_only)
|
||||
channel.isPrivate() -> stringRes(R.string.relay_group_badge_private)
|
||||
else -> null
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.clip(BlockShape)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().height(BannerHeight).background(gradient)) {
|
||||
if (picture != null) {
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
// Scrim only over the lower half, where the name sits — a full-height wash would mute the
|
||||
// picture the block exists to show.
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.45f to Color.Transparent,
|
||||
1f to Color.Black.copy(alpha = 0.68f),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if (badge != null) {
|
||||
Text(
|
||||
text = badge,
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = name,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 9.dp)) {
|
||||
ChannelRosterLine(channel, memberCount, accountViewModel)
|
||||
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "3 people you follow · 24 members · relay.host".
|
||||
*
|
||||
* The faces are [RelayGroupChannel.participatingFollows], not an arbitrary slice of the roster: whether
|
||||
* anyone you already follow is in a channel says far more about whether you want to be there than three
|
||||
* strangers' avatars do. Falls back to the bare count when the answer is nobody.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChannelRosterLine(
|
||||
channel: RelayGroupChannel,
|
||||
memberCount: Int,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val follows by accountViewModel.account.kind3FollowList.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val known =
|
||||
remember(channel, follows) {
|
||||
channel.participatingFollows(follows.authors).take(3)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
known.forEach { pubkey ->
|
||||
key(pubkey) {
|
||||
FollowedMemberFace(pubkey, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
// "24 members · relay.host". The count carries its unit through the same plural every other
|
||||
// group surface uses (the workspace channel list, discovery, the parent picker), because a bare
|
||||
// number sitting immediately after the faces reads as counting the faces — "3 people you
|
||||
// follow" — which is the one thing it does not mean.
|
||||
val host = channel.groupId.relayUrl.displayUrl()
|
||||
Text(
|
||||
text =
|
||||
if (memberCount > 0) {
|
||||
pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount) + " · " + host
|
||||
} else {
|
||||
host
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FollowedMemberFace(
|
||||
pubkey: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val user = remember(pubkey) { LocalCache.getOrCreateUser(pubkey) }
|
||||
|
||||
// The plain inner picture rather than BaseUserPicture: everyone on this line is by definition
|
||||
// somebody the viewer follows, so the following badge BaseUserPicture stacks on top would be three
|
||||
// identical checkmarks at 19dp saying nothing.
|
||||
ObserveAndDrawInnerUserPicture(user, RosterFaceSize, accountViewModel)
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable two-stop gradient derived from the group id, so every channel keeps the same colour across
|
||||
* launches and devices without anything being stored. Hue is the only thing the id chooses;
|
||||
* saturation and lightness are fixed so no channel can land on something unreadable behind white text.
|
||||
*/
|
||||
private fun identityGradient(groupId: String): Brush {
|
||||
var hash = 0
|
||||
groupId.forEach { hash = it.code + ((hash shl 5) - hash) }
|
||||
val hue = ((hash % 360) + 360) % 360
|
||||
return Brush.linearGradient(
|
||||
listOf(
|
||||
Color.hsl(hue.toFloat(), 0.52f, 0.42f),
|
||||
Color.hsl(((hue + 42) % 360).toFloat(), 0.58f, 0.28f),
|
||||
),
|
||||
)
|
||||
}
|
||||
+26
-3
@@ -60,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal.MusicPlaylistsFee
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal.MusicTracksFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInvitesState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
|
||||
@@ -142,8 +141,6 @@ class AccountFeedContentStates(
|
||||
|
||||
val notificationsOpenPolls = OpenPollsState(account, scope)
|
||||
|
||||
/** Channels somebody added the viewer to, awaiting a show-on-Messages decision. */
|
||||
val channelInvites = ChannelInvitesState(account, scope)
|
||||
val notificationSummary = NotificationSummaryState(account)
|
||||
|
||||
val feedListOptions = TopNavFilterState(account, scope)
|
||||
@@ -187,6 +184,32 @@ class AccountFeedContentStates(
|
||||
}
|
||||
}
|
||||
|
||||
// A pending channel invite is a row on Notifications and on Messages › New Requests, but
|
||||
// nothing about answering one flows through newEventBundles: accepting writes my kind-10009,
|
||||
// dismissing touches only local settings, and classification lands a kind-39000 that is not
|
||||
// itself a notification. Each of those changes whether the 44100 still belongs in either
|
||||
// feed, so rebuild when the projection moves — otherwise an answered invite would sit on the
|
||||
// tab until an unrelated event refreshed it. Arriving invites come through here too: neither
|
||||
// filter picks a 44100 up additively, so this is what puts a new one on screen.
|
||||
//
|
||||
// The card feeds need `clear()` first, because answering an invite REMOVES a row and their
|
||||
// additive refresh cannot express that: it diffs `feed()` against `lastNotes`, finds no *new*
|
||||
// notes, and bails without touching the list — leaving the answered invite in place. Clearing
|
||||
// drops the additive fast path so the refresh rebuilds the whole list, which is the only
|
||||
// branch that can shrink. FeedContentState (dmNew) rebuilds from feed() on invalidateData()
|
||||
// regardless, so it shrinks on its own.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.channelInvites.pendingByEventId
|
||||
.drop(1)
|
||||
.collect {
|
||||
listOf(notifications, notificationsFollowing, notificationsEveryone).forEach {
|
||||
it.clear()
|
||||
it.invalidateData()
|
||||
}
|
||||
dmNew.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
// Joining/leaving a geohash location channel (kind 10081 list) changes the Messages list but
|
||||
// no event flows through LocalCache, so force a rebuild — otherwise a just-joined cell (whose
|
||||
// ephemeral messages haven't arrived yet) wouldn't show its placeholder row until later.
|
||||
|
||||
+20
-8
@@ -43,7 +43,6 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
||||
@@ -105,7 +104,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.Marm
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUpload
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUploader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.markRoomNoteAsRead
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.rowHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.rowHasUnread
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
|
||||
@@ -444,7 +443,7 @@ class AccountViewModel(
|
||||
/**
|
||||
* The bottom-bar envelope dot: true when ANY Messages row is showing its blue dot.
|
||||
*
|
||||
* Per-row via [rowHasUnreadFlow], which mirrors what each row composable computes for itself.
|
||||
* Per-row via [rowHasUnread], which mirrors what each row composable computes for itself.
|
||||
* This used to call `unreadPrivateChatRoute` directly, which returns null for anything that is not
|
||||
* `ChatroomKeyable` — so only NIP-17/NIP-04 DMs counted, and a public chat, ephemeral room, geohash
|
||||
* cell, Marmot group, NIP-29/Buzz channel or Concord channel could sit there with a visible dot
|
||||
@@ -460,7 +459,7 @@ class AccountViewModel(
|
||||
MutableStateFlow(null)
|
||||
}
|
||||
}.flatMapLatest { loadedFeedState ->
|
||||
val flows = loadedFeedState?.list?.mapNotNull { chat -> rowHasUnreadFlow(chat, account) }
|
||||
val flows = loadedFeedState?.list?.mapNotNull { chat -> rowHasUnread(chat, account)?.flow }
|
||||
|
||||
if (!flows.isNullOrEmpty()) {
|
||||
combine(flows) { newItems ->
|
||||
@@ -1731,7 +1730,6 @@ class AccountViewModel(
|
||||
launchSigner {
|
||||
account.settings.undismissChannelInvite(channel.groupId.id)
|
||||
account.follow(channel)
|
||||
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1763,14 +1761,21 @@ class AccountViewModel(
|
||||
*/
|
||||
fun dismissChannelInvite(channelId: String) {
|
||||
account.settings.dismissChannelInvite(channelId)
|
||||
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channelId)
|
||||
}
|
||||
|
||||
/** Actually leave: kind-9022 to the host relay, and drop it from my list and the pending set. */
|
||||
/**
|
||||
* Actually leave: kind-9022 to the host relay, which answers with a kind-44101 that supersedes the
|
||||
* add, so the projection drops the card on its own.
|
||||
*
|
||||
* Deliberately does NOT record a local dismissal. That would clear the card a relay round-trip
|
||||
* sooner, but `dismissedChannelInvites` is keyed by channel id and persisted forever, so it would
|
||||
* also swallow a *later, legitimate* re-add to the same channel — the viewer would be put back in
|
||||
* and never told. Waiting for the relay's own withdrawal keeps "am I a member" answerable from the
|
||||
* events alone. Ignore is the action for "don't ask me again"; this one is for "take me out".
|
||||
*/
|
||||
fun leaveChannelInvite(channel: RelayGroupChannel) =
|
||||
launchSigner {
|
||||
account.relayGroups.leaveRelayGroup(channel)
|
||||
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2036,6 +2041,13 @@ class AccountViewModel(
|
||||
account.toggleChatroomPin(room)
|
||||
}
|
||||
|
||||
fun mutedPublicChatsFlow(): StateFlow<Set<String>> = account.settings.mutedPublicChats
|
||||
|
||||
fun toggleMutedPublicChat(channelId: String) =
|
||||
launchSigner {
|
||||
account.toggleMutedPublicChat(channelId)
|
||||
}
|
||||
|
||||
fun updateZapAmounts(
|
||||
amountSet: List<Long>,
|
||||
selectedZapType: LnZapEvent.ZapType,
|
||||
|
||||
+24
-13
@@ -139,7 +139,7 @@ fun AgentAttestationScreen(
|
||||
// Agent side: hold an attestation an owner gave you, so this account
|
||||
// authenticates to the owner's Buzz relays as a virtual member. Available to
|
||||
// any signer — holding a credential doesn't require the raw key.
|
||||
HoldAttestationSection(myPubkey = myPubkey)
|
||||
HoldAttestationSection(myPubkey = myPubkey, attestation = accountViewModel.account.buzzAttestation)
|
||||
|
||||
// Owner side: issue an attestation for an agent key. Needs the raw private key.
|
||||
val privKey = keyPair.privKey
|
||||
@@ -153,15 +153,17 @@ fun AgentAttestationScreen(
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-side: paste an `auth` tag an owner issued to this account's key. It is verified
|
||||
* against [myPubkey] and, on success, stored in [BuzzHeldAttestations] so the auth
|
||||
* coordinator attaches it when this account AUTHs to a Buzz relay. Persisted across
|
||||
* restarts (device-global) by `BuzzAttestationPreferences`.
|
||||
* Agent-side: paste an `auth` tag an owner issued to this account's key. [parseHeldAttestation]
|
||||
* turns it into a typed failure the field can show, and [BuzzHeldAttestations.put] re-checks the
|
||||
* signature before storing, so the auth coordinator attaches it when this account AUTHs to a Buzz
|
||||
* relay. Persisted across restarts, per account, by `BuzzAttestationPreferences`.
|
||||
*/
|
||||
@Composable
|
||||
private fun HoldAttestationSection(myPubkey: String) {
|
||||
val held by BuzzHeldAttestations.flow.collectAsState()
|
||||
val mine = held[myPubkey]
|
||||
private fun HoldAttestationSection(
|
||||
myPubkey: String,
|
||||
attestation: BuzzHeldAttestations,
|
||||
) {
|
||||
val mine = attestation.flow.collectAsState().value
|
||||
|
||||
var input by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
@@ -183,7 +185,7 @@ private fun HoldAttestationSection(myPubkey: String) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = { BuzzHeldAttestations.remove(myPubkey) }) {
|
||||
OutlinedButton(onClick = { attestation.clear() }) {
|
||||
Text(stringRes(R.string.buzz_attest_remove))
|
||||
}
|
||||
} else {
|
||||
@@ -210,9 +212,15 @@ private fun HoldAttestationSection(myPubkey: String) {
|
||||
when (val outcome = parseHeldAttestation(input, myPubkey)) {
|
||||
is HoldOutcome.Failure -> error = outcome.message
|
||||
is HoldOutcome.Success -> {
|
||||
BuzzHeldAttestations.put(myPubkey, outcome.attestation)
|
||||
input = ""
|
||||
error = null
|
||||
// put() re-checks the signature, so honour its answer instead of
|
||||
// assuming it stored: clearing the field on a rejected paste would
|
||||
// read as success and leave the account holding nothing.
|
||||
if (attestation.put(outcome.attestation)) {
|
||||
input = ""
|
||||
error = null
|
||||
} else {
|
||||
error = NOT_FOR_THIS_ACCOUNT
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -236,6 +244,9 @@ private sealed interface HoldOutcome {
|
||||
) : HoldOutcome
|
||||
}
|
||||
|
||||
/** Shown for both rejection paths — the parse-time check and [BuzzHeldAttestations.put]'s. */
|
||||
private const val NOT_FOR_THIS_ACCOUNT = "This attestation does not authorize the current account, or its signature is invalid."
|
||||
|
||||
/**
|
||||
* Parses a pasted `["auth", owner, conditions, sig]` JSON array and verifies it
|
||||
* authorizes [myPubkey]. Returns a human-readable failure on malformed JSON, a
|
||||
@@ -259,7 +270,7 @@ private fun parseHeldAttestation(
|
||||
OwnerAttestation.parse(tag)
|
||||
?: return HoldOutcome.Failure("Not a NIP-OA auth tag.")
|
||||
if (!attestation.verify(myPubkey)) {
|
||||
return HoldOutcome.Failure("This attestation does not authorize the current account, or its signature is invalid.")
|
||||
return HoldOutcome.Failure(NOT_FOR_THIS_ACCOUNT)
|
||||
}
|
||||
return HoldOutcome.Success(attestation)
|
||||
}
|
||||
|
||||
+1
-2
@@ -26,7 +26,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -107,7 +106,7 @@ class AgentConsoleViewModel : ViewModel() {
|
||||
this.scopeRelay = relay
|
||||
this.account = account
|
||||
relay?.let {
|
||||
val newlyJoined = BuzzWorkspaces.join(it)
|
||||
val newlyJoined = account.buzzWorkspaces.join(it)
|
||||
viewModelScope.launch { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
|
||||
if (newlyJoined) reconnectPoolAfterJoin(account.client)
|
||||
}
|
||||
|
||||
+73
-130
@@ -22,174 +22,117 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.buzz.buzzChannelTypes
|
||||
import com.vitorpamplona.amethyst.model.buzz.classifyBuzzChannel
|
||||
import com.vitorpamplona.amethyst.model.buzz.membershipNoticeFilter
|
||||
import com.vitorpamplona.amethyst.model.buzz.membershipNotices
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
|
||||
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Always-on discovery of the viewer's Buzz **DM channels** across every joined workspace relay, mounted
|
||||
* once high in the logged-in tree ([com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage]).
|
||||
*
|
||||
* The deployed relay does not expose a queryable DM list; it addresses each member a kind-44100
|
||||
* member-added notification (`#p` = me). This warm-auths a `#p=me` fetch of 44100 (+ the 30622 visibility
|
||||
* snapshot) across the joined relays, records the channels into [BuzzDmChannels], fetches each channel's
|
||||
* 39000-39003 directory (so its `t`=dm marker + participants land in `LocalCache`), and keeps a live
|
||||
* `#p=me` 44100 subscription open for new DMs. The companion [BuzzDmJoinedChatTailPreload] then keeps the
|
||||
* discovered channels' messages warm app-wide — which is what lets a Buzz DM show on the Notifications tab
|
||||
* and in push without the viewer opening the conversation first.
|
||||
* member-added notification (`#p` = me). Those arrive through the ordinary subscription pipeline —
|
||||
* [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.buzz.BuzzMembershipEoseManager]
|
||||
* owns the one `#p=me` REQ per workspace relay — so this reads them back out of [LocalCache], fetches
|
||||
* each discovered channel's 39000-39003 directory (so its `t`=dm marker + participants land), and
|
||||
* records the ones that turn out to be DMs into [BuzzDmChannels]. The companion
|
||||
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.BuzzDmJoinedChatTailPreload]
|
||||
* then keeps those channels' messages warm app-wide — which is what lets a Buzz DM show on the
|
||||
* Notifications tab and in push without the viewer opening the conversation first.
|
||||
*
|
||||
* This mirrors [BuzzDmListViewModel]'s discovery, but account-scoped and always-on rather than bound to
|
||||
* the open inbox screen; the inbox keeps its own scoped copy for its per-relay projection.
|
||||
* Everything else somebody added the viewer to is a named channel; it must NOT be silently subscribed,
|
||||
* so it stays out of [BuzzDmChannels] and surfaces as a prompt instead — see
|
||||
* [com.vitorpamplona.amethyst.model.buzz.ChannelInvitesState], which projects the
|
||||
* same cached notices.
|
||||
*
|
||||
* ### Recompute, don't accumulate
|
||||
*
|
||||
* Each pass derives the whole membership picture from the cache and *declares* the result
|
||||
* ([BuzzDmChannels.replace]). The previous incremental version — record every 44100, then delete the
|
||||
* ones classification rejected — had no memory of its own rejections, so the next delivery of the same
|
||||
* event re-added them and the two steps fought each other in a loop. A recomputation cannot fight
|
||||
* itself: the same events always produce the same set.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzDmDiscoveryPreload(accountViewModel: AccountViewModel) {
|
||||
val account = accountViewModel.account
|
||||
val joined by BuzzWorkspaces.flow.collectAsStateWithLifecycle()
|
||||
|
||||
// Restart the whole discovery (initial warm-auth fetch + live 44100 subs) whenever the joined
|
||||
// workspace set changes; the LaunchedEffect scope owns the live subscriptions and cancels them on
|
||||
// account switch or dispose.
|
||||
LaunchedEffect(account, joined) {
|
||||
if (joined.isEmpty()) return@LaunchedEffect
|
||||
runBuzzDmDiscovery(account, joined)
|
||||
LaunchedEffect(account) {
|
||||
runBuzzDmDiscovery(account)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm-auth the initial 44100/30622 `#p=me` read across [relays], record every discovered channel, fetch
|
||||
* their directories, then keep a live 44100 subscription per relay open until the caller's scope is
|
||||
* cancelled. Suspends for the lifetime of the live subscriptions.
|
||||
* Recompute the viewer's DM set from the cached membership notices, fetching any directory still
|
||||
* missing, and keep doing it for the lifetime of the caller's scope.
|
||||
*/
|
||||
private suspend fun runBuzzDmDiscovery(
|
||||
account: Account,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) = coroutineScope {
|
||||
private suspend fun runBuzzDmDiscovery(account: Account) {
|
||||
val me = account.userProfile().pubkeyHex
|
||||
|
||||
// A joined workspace is first-party: pre-approve NIP-42 so the `#p=me` DM reads authenticate (the
|
||||
// restore-from-disk path doesn't set this, unlike the inbox/import/console entry points).
|
||||
relays.forEach { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
|
||||
|
||||
val discoveryFilters =
|
||||
listOf(
|
||||
Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(me))),
|
||||
Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(me))),
|
||||
combine(
|
||||
LocalCache.observeNotes(membershipNoticeFilter(me)),
|
||||
// A channel's type is only decidable once its kind-39000 is in the cache, and that lands
|
||||
// *after* the notice that revealed the channel — so the directory arriving has to re-run the
|
||||
// classification. The emission carries the types themselves rather than a count of notes,
|
||||
// because `LocalCache.consume(GroupMetadataEvent)` wakes this observer before it copies the
|
||||
// event into the channel: classifying off the channel at this instant reads one that is still
|
||||
// empty and answers UNKNOWN, and nothing emits again to correct it. See [buzzChannelTypes].
|
||||
LocalCache
|
||||
.observeNotes(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
|
||||
.map { buzzChannelTypes(it) }
|
||||
.distinctUntilChanged(),
|
||||
) { _, knownTypes ->
|
||||
// Read the joined set per pass, not once: which relay vouched for a notice depends on it,
|
||||
// and restore-from-disk can land after the cache already holds notices.
|
||||
val workspaces = account.buzzWorkspaces.flow.value
|
||||
BuzzChannelInvites.currentMemberships(LocalCache.membershipNotices(me, workspaces)) to knownTypes
|
||||
}.collectLatest { (memberships, knownTypes) ->
|
||||
fetchMissingDirectories(account, memberships, knownTypes)
|
||||
BuzzDmChannels.replace(
|
||||
me,
|
||||
memberships.filter { (id, relay) ->
|
||||
classifyBuzzChannel(LocalCache, id, relay, knownTypes) == ChannelClassification.DM
|
||||
},
|
||||
)
|
||||
// `#p`-gated reads: use the warm-auth fetch so an `auth-required` CLOSED authenticates and retries
|
||||
// rather than returning empty.
|
||||
account.client.fetchAllWithHooks(
|
||||
filters = relays.associateWith { discoveryFilters },
|
||||
idleTimeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { relay, event ->
|
||||
(event as? MemberAddedNotificationEvent)?.let { recordDiscovery(me, it, relay) }
|
||||
false
|
||||
}
|
||||
fetchDmMetadata(account, me)
|
||||
classifyDiscoveredChannels(account, me)
|
||||
|
||||
relays.forEach { relay ->
|
||||
launch {
|
||||
val filter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(me)))
|
||||
account.client.subscribeAsFlow(relay, filter).collect { events ->
|
||||
var changed = false
|
||||
events.filterIsInstance<MemberAddedNotificationEvent>().forEach { e ->
|
||||
if (recordDiscovery(me, e, relay)) changed = true
|
||||
}
|
||||
if (changed) {
|
||||
fetchDmMetadata(account, me)
|
||||
classifyDiscoveredChannels(account, me)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the NIP-29 directory (39000-39003) of every known DM channel so its `t`=dm marker + roster load. */
|
||||
private suspend fun fetchDmMetadata(
|
||||
/**
|
||||
* Fetch the NIP-29 directory (39000-39003) of every channel whose type we don't know yet, so its `t`=dm
|
||||
* marker and roster load.
|
||||
*
|
||||
* Only the unclassified ones: a channel keeps its metadata in the cache for the session, so re-asking
|
||||
* for it on every pass would put a burst of `#d` reads on the relay each time a single new notice
|
||||
* arrives. This converges — the fetch lands the 39000, which re-runs the pass, which now finds nothing
|
||||
* missing.
|
||||
*/
|
||||
private suspend fun fetchMissingDirectories(
|
||||
account: Account,
|
||||
viewer: HexKey,
|
||||
memberships: Map<String, NormalizedRelayUrl>,
|
||||
knownTypes: Map<String, ChannelClassification>,
|
||||
) {
|
||||
val byRelay =
|
||||
BuzzDmChannels
|
||||
.channelsFor(viewer)
|
||||
memberships
|
||||
.filterKeys { id -> memberships[id]?.let { classifyBuzzChannel(LocalCache, id, it, knownTypes) } == ChannelClassification.UNKNOWN }
|
||||
.entries
|
||||
.groupBy({ it.value }, { it.key })
|
||||
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
|
||||
if (byRelay.isEmpty()) return
|
||||
account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a kind-44100 "you were added" into [BuzzDmChannels] so its directory can be fetched, and — when
|
||||
* somebody *else* did the adding — into [BuzzChannelInvites] as well.
|
||||
*
|
||||
* The relay emits this same kind for a self-join with `actor == me`, so the actor is what separates "I
|
||||
* joined this" from "a stranger put me in this". Everything is provisionally treated as a DM here because
|
||||
* the channel's type only becomes knowable once its kind-39000 lands; [classifyDiscoveredChannels] sorts
|
||||
* them out immediately afterwards.
|
||||
*/
|
||||
private fun recordDiscovery(
|
||||
me: HexKey,
|
||||
event: MemberAddedNotificationEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
): Boolean {
|
||||
val channelId = event.channel() ?: return false
|
||||
val changed = BuzzDmChannels.record(me, channelId, relay)
|
||||
val actor = event.actor()
|
||||
if (actor == null || !actor.equals(me, ignoreCase = true)) {
|
||||
BuzzChannelInvites.record(me, BuzzChannelInvite(channelId, relay, actor, event.createdAt))
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits what discovery found into DMs and named channels, now that each channel's kind-39000 has loaded.
|
||||
*
|
||||
* A `t = dm` channel is a real DM: it stays in [BuzzDmChannels], whose always-on tail keeps it warm so it
|
||||
* can reach Notifications without being opened, and it is never an "invite". Anything else is a named
|
||||
* channel somebody added the viewer to; it must NOT be silently subscribed, so it is dropped from
|
||||
* [BuzzDmChannels] and left in [BuzzChannelInvites] for the viewer to accept or dismiss.
|
||||
*
|
||||
* Channels already in the viewer's kind-10009 (accepted earlier, or joined from this device) are not
|
||||
* invites — the ordinary joined-group path owns them.
|
||||
*/
|
||||
private fun classifyDiscoveredChannels(
|
||||
account: Account,
|
||||
me: HexKey,
|
||||
) {
|
||||
val joined =
|
||||
account.relayGroupList.liveRelayGroupList.value
|
||||
.mapNotNullTo(mutableSetOf()) { it.groupId }
|
||||
|
||||
BuzzDmChannels.channelsFor(me).forEach { (channelId, relay) ->
|
||||
val metadata = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.event
|
||||
when {
|
||||
// Type not known yet — leave both entries alone and re-run when the directory lands.
|
||||
metadata == null -> Unit
|
||||
metadata.isBuzzDmChannel() -> BuzzChannelInvites.remove(me, channelId)
|
||||
else -> {
|
||||
BuzzDmChannels.remove(me, channelId)
|
||||
if (channelId in joined) BuzzChannelInvites.remove(me, channelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
-23
@@ -23,13 +23,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.buzz.membershipNoticeFilter
|
||||
import com.vitorpamplona.amethyst.model.buzz.membershipNotices
|
||||
import com.vitorpamplona.amethyst.model.filter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
|
||||
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
|
||||
@@ -39,7 +41,6 @@ import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
|
||||
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -111,7 +112,14 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
val lastActivity: Long,
|
||||
)
|
||||
|
||||
private fun relays(): Set<NormalizedRelayUrl> = scopeRelay?.let { setOf(it) } ?: (BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value)
|
||||
private fun relays(): Set<NormalizedRelayUrl> =
|
||||
scopeRelay?.let { setOf(it) } ?: (
|
||||
account
|
||||
?.buzzWorkspaces
|
||||
?.flow
|
||||
?.value
|
||||
.orEmpty() + BuzzRelayDialect.flow.value
|
||||
)
|
||||
|
||||
/**
|
||||
* Binds to [account] scoped to the community [relayUrl]. Marks that relay a joined workspace and
|
||||
@@ -128,7 +136,7 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return
|
||||
this.scopeRelay = relay
|
||||
|
||||
val newlyJoined = BuzzWorkspaces.join(relay)
|
||||
val newlyJoined = account.buzzWorkspaces.join(relay)
|
||||
viewModelScope.launch { account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) }
|
||||
if (newlyJoined) reconnectPoolAfterJoin(account.client)
|
||||
|
||||
@@ -284,8 +292,14 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
).maxOfOrNull { it.createdAt() ?: 0L } ?: 0L
|
||||
|
||||
/**
|
||||
* Keeps a live 44100 + 30622 REQ open (so new DMs / hide changes arrive) and re-projects the
|
||||
* inbox when the registry or dialect set moves. Idempotent; torn down with the ViewModel.
|
||||
* Re-projects the inbox as new DMs and hide changes arrive. Idempotent; torn down with the ViewModel.
|
||||
*
|
||||
* The 44100/30622 stream itself is **not** subscribed here. `bind` marks this community's relay a
|
||||
* joined workspace, which is exactly what
|
||||
* [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.buzz.BuzzMembershipEoseManager]
|
||||
* keys its always-on `#p=me` subscription on — so opening this screen used to put a second, identical
|
||||
* REQ on the same relay. Observing [LocalCache] instead means the screen sees the same events at the
|
||||
* same time for free, and the relay sees one subscription.
|
||||
*/
|
||||
private fun startLive() {
|
||||
val account = account ?: return
|
||||
@@ -294,28 +308,50 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
|
||||
liveJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
relays().forEach { relay ->
|
||||
launch {
|
||||
val filter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
|
||||
account.client.subscribeAsFlow(relay, filter).collect { events ->
|
||||
var changed = false
|
||||
events.filterIsInstance<MemberAddedNotificationEvent>().forEach { e ->
|
||||
e.channel()?.let { if (memberChannels.put(it, relay) == null) changed = true }
|
||||
}
|
||||
if (changed) {
|
||||
fetchMetadata(account)
|
||||
rebuildRows(account)
|
||||
}
|
||||
launch {
|
||||
LocalCache.observeNotes(membershipNoticeFilter(myPubkey)).collect {
|
||||
// The emission is only the signal; the notices come from a cache scan, because
|
||||
// `observeNotes` cannot seed these kinds (see [membershipNotices]).
|
||||
//
|
||||
// Re-read the relay scope per pass rather than snapshotting it: a workspace
|
||||
// joined while this screen is open should bring its channels with it.
|
||||
val scoped = relays()
|
||||
val workspaces = account.buzzWorkspaces.flow.value
|
||||
val memberships =
|
||||
BuzzChannelInvites
|
||||
.currentMemberships(LocalCache.membershipNotices(myPubkey, workspaces))
|
||||
.filterValues { it in scoped }
|
||||
var changed = false
|
||||
memberships.forEach { (channelId, relay) ->
|
||||
if (memberChannels.put(channelId, relay) == null) changed = true
|
||||
}
|
||||
// A kind-44101 takes the membership away: drop the row rather than leaving a
|
||||
// conversation the relay no longer lets us read.
|
||||
//
|
||||
// Only channels the scan actually has a *removal* for. Anything else in
|
||||
// `memberChannels` was put there by the seed or the one-shot fetch, which see
|
||||
// relays this scan may not cover — treating "absent from this pass" as "gone"
|
||||
// would let one pass wipe rows nothing withdrew.
|
||||
val withdrawn =
|
||||
LocalCache
|
||||
.membershipNotices(myPubkey, workspaces)
|
||||
.let { BuzzChannelInvites.latestPerChannel(it) }
|
||||
.filterValues { it.removed }
|
||||
.keys
|
||||
val gone = memberChannels.keys.filter { it in withdrawn }
|
||||
if (gone.isNotEmpty()) {
|
||||
gone.forEach { memberChannels.remove(it) }
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
fetchMetadata(account)
|
||||
rebuildRows(account)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
val filter = Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
|
||||
account.client.subscribeAsFlow(relay, filter).collect { /* consumed → BuzzDmRegistry.hidden */ }
|
||||
}
|
||||
}
|
||||
// Re-project when my hidden set (30622) or the joined-relay set changes.
|
||||
launch {
|
||||
combine(BuzzDmRegistry.hidden, BuzzWorkspaces.flow, BuzzRelayDialect.flow) { _, _, _ -> }
|
||||
combine(BuzzDmRegistry.hidden, account.buzzWorkspaces.flow, BuzzRelayDialect.flow) { _, _, _ -> }
|
||||
.collect { rebuildRows(account) }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -55,7 +55,6 @@ 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.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -189,7 +188,7 @@ fun BuzzInviteScreen(
|
||||
// authenticates without a prompt, then hand off to the in-app window.nostr
|
||||
// browser to accept terms + sign the claim.
|
||||
RelayUrlNormalizer.normalizeOrNull(invite.relayUrl())?.let { relay ->
|
||||
BuzzWorkspaces.join(relay)
|
||||
accountViewModel.account.buzzWorkspaces.join(relay)
|
||||
scope.launch { accountViewModel.account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) }
|
||||
}
|
||||
FavoriteAppLauncher.launchUrl(context, link)
|
||||
|
||||
+1
-2
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -104,7 +103,7 @@ class BuzzRelayImportViewModel : ViewModel() {
|
||||
|
||||
// The user came here to import from THIS relay: remember it as a joined workspace (persisted,
|
||||
// marks the Buzz dialect) and pre-approve NIP-42 auth so the `#p=me` read below is served.
|
||||
val newlyJoined = BuzzWorkspaces.join(normalized)
|
||||
val newlyJoined = account.buzzWorkspaces.join(normalized)
|
||||
viewModelScope.launch { account.relayAuthLedger.setDecision(normalized.url, RelayAuthDecision.ALLOW) }
|
||||
|
||||
// Unlocks the persistent group-roster (39002) subscription — see [reconnectPoolAfterJoin].
|
||||
|
||||
+3
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.EditButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.LeaveChatButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.LinkChatButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.MuteChatButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.OpenChatButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.ShareChatButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -188,6 +189,8 @@ fun LongChannelActionOptions(
|
||||
|
||||
ShareChatButton(channel, accountViewModel, nav)
|
||||
|
||||
MuteChatButton(channel, accountViewModel)
|
||||
|
||||
EditButtonIfIamCreator(channel, accountViewModel, nav)
|
||||
|
||||
LeaveButtonIfFollowing(channel, accountViewModel, nav)
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
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.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
|
||||
@Composable
|
||||
fun MuteChatButton(
|
||||
channel: PublicChatChannel,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val mutedChats by accountViewModel.mutedPublicChatsFlow().collectAsStateWithLifecycle()
|
||||
val isMuted = channel.idHex in mutedChats
|
||||
|
||||
val label =
|
||||
stringRes(
|
||||
if (isMuted) R.string.unmute_notifications else R.string.mute_notifications,
|
||||
)
|
||||
|
||||
FilledTonalButton(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.width(50.dp),
|
||||
onClick = { accountViewModel.toggleMutedPublicChat(channel.idHex) },
|
||||
contentPadding = ZeroPadding,
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications,
|
||||
contentDescription = label,
|
||||
modifier = Size20Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -39,16 +39,18 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* Pin/Unpin a Buzz channel (a device-local favorite — [BuzzChannelStars]). Moved off the per-channel
|
||||
* list row into the opened channel's/forum's top-bar overflow, so the list row stays a clean
|
||||
* tap-to-open target. Reads the live starred set so the label + icon reflect the current state.
|
||||
* Pin/Unpin a Buzz channel (a local favorite of this account — [BuzzChannelStars]). Moved off the
|
||||
* per-channel list row into the opened channel's/forum's top-bar overflow, so the list row stays a
|
||||
* clean tap-to-open target. Reads the live starred set so the label + icon reflect the current state.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzPinDropdownItem(
|
||||
groupId: GroupId,
|
||||
accountViewModel: AccountViewModel,
|
||||
closeMenu: () -> Unit,
|
||||
) {
|
||||
val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle()
|
||||
val stars = accountViewModel.account.buzzChannelStars
|
||||
val starred by stars.flow.collectAsStateWithLifecycle()
|
||||
val isStarred = groupId.id in starred
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
@@ -62,7 +64,7 @@ fun BuzzPinDropdownItem(
|
||||
text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) },
|
||||
onClick = {
|
||||
closeMenu()
|
||||
BuzzChannelStars.toggle(groupId.id)
|
||||
stars.toggle(groupId.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -68,7 +68,6 @@ 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.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
@@ -289,7 +288,8 @@ fun RelayGroupChannelListScreen(
|
||||
// visibly reshuffled in the second after opening, and came back differently each visit. Ordering
|
||||
// by a property of the channel instead makes the first frame the final order; a channel whose
|
||||
// 39000 hasn't arrived sorts by its id until the name lands.
|
||||
val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle()
|
||||
val starred by accountViewModel.account.buzzChannelStars.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
fun buzzSortKey(groupId: GroupId): String = channelsById[groupId.id]?.toBestDisplayName()?.lowercase() ?: groupId.id
|
||||
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ private fun RelayGroupThreads(
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
|
||||
BuzzPinDropdownItem(channel.groupId, accountViewModel) { menuOpen = false }
|
||||
RelayGroupMessagesDropdownItem(channel, accountViewModel) { menuOpen = false }
|
||||
if (isAdmin) {
|
||||
val archived = channel.isArchived()
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ fun RelayGroupTopBar(
|
||||
// Pin/Unpin moved here off the community-list row. A local favorite, so it's offered
|
||||
// for any Buzz channel/forum regardless of membership; DMs are never pinned.
|
||||
if (isBuzzRelay && !isDm) {
|
||||
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
|
||||
BuzzPinDropdownItem(channel.groupId, accountViewModel) { menuOpen = false }
|
||||
}
|
||||
// A DM's Add/Remove-from-Messages, moved off the DM list row. It rides the per-viewer
|
||||
// 30622 hide snapshot (kind-41012 hide / re-open), not the kind-10009 list, and is
|
||||
|
||||
+178
-25
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.buzz.toMembershipNotice
|
||||
import com.vitorpamplona.amethyst.model.chatMessageMarksRoomAsRead
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.model.privateChatLastReadRoute
|
||||
@@ -81,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl
|
||||
@@ -107,6 +110,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBubbleModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
@@ -119,6 +123,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
||||
@Composable
|
||||
fun ChatroomHeaderCompose(
|
||||
@@ -208,6 +213,16 @@ private fun ChatroomEntry(
|
||||
return
|
||||
}
|
||||
|
||||
// A relay's "somebody added you" verdict (kind 44100) stands in for the group it invites me to.
|
||||
// Matched before the generic group-scoped fallback below, which would otherwise render it as an
|
||||
// ordinary joined-group row: with the relay keypair's npub and the raw JSON body as the preview,
|
||||
// and a long-press menu offering to leave a group I never agreed to join.
|
||||
val inviteEvent = lastMessage.event as? MemberAddedNotificationEvent
|
||||
if (inviteEvent != null) {
|
||||
ChannelInviteRoomCompose(lastMessage, accountViewModel, nav)
|
||||
return
|
||||
}
|
||||
|
||||
// A NIP-29 group message whose channel gatherer didn't attach (e.g. loaded before its channel
|
||||
// existed, or via a path that skips attach) has no case in the when() below and would blank out.
|
||||
// Resolve the group from its `h` tag + provenance relay and render the group row anyway.
|
||||
@@ -292,15 +307,34 @@ private fun ChannelRoomCompose(
|
||||
noteEvent?.content?.take(200)
|
||||
}
|
||||
|
||||
val lastReadTime by accountViewModel.account.loadLastReadFlow("Channel/${channel.idHex}").collectAsStateWithLifecycle()
|
||||
// One predicate for the row dot and the bottom-bar badge — see rowHasUnread.
|
||||
// `emptyFlow()` is a shared singleton, so a row that can never be unread allocates nothing.
|
||||
// The seed matters: collection only starts after the first composition, so without it every
|
||||
// row would paint dotless for a frame and then correct itself while scrolling.
|
||||
val unread = remember(lastMessage) { rowHasUnread(lastMessage, accountViewModel.account) }
|
||||
val hasNewMessages by (unread?.flow ?: emptyFlow()).collectAsStateWithLifecycle(unread?.initial ?: false)
|
||||
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
// Kept as a State (no `by`) so `.value` is read only inside the title and menu-text
|
||||
// slots, confining mute-toggle invalidations to those scopes.
|
||||
val mutedChats = accountViewModel.mutedPublicChatsFlow().collectAsStateWithLifecycle()
|
||||
|
||||
ChannelName(
|
||||
channelIdHex = channel.idHex,
|
||||
channelPicture = channelPicture,
|
||||
channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, MaterialSymbols.Public, R.string.public_chat, modifier) },
|
||||
channelTitle = { modifier ->
|
||||
val isMuted = channel.idHex in mutedChats.value
|
||||
ChannelTitleWithLabelInfo(
|
||||
channelName,
|
||||
if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Public,
|
||||
R.string.public_chat,
|
||||
modifier,
|
||||
labelContentDescription = if (isMuted) stringRes(R.string.muted_chat_content_description) else null,
|
||||
)
|
||||
},
|
||||
channelLastTime = lastMessage.createdAt(),
|
||||
channelLastContent = "$authorName: $description",
|
||||
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
|
||||
hasNewMessages = hasNewMessages,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif =
|
||||
@@ -308,7 +342,31 @@ private fun ChannelRoomCompose(
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
onClick = { nav.nav(routeFor(channel)) },
|
||||
onLongClick = { menuOpen = true },
|
||||
)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = menuOpen,
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringRes(
|
||||
if (channel.idHex in mutedChats.value) {
|
||||
R.string.unmute_notifications
|
||||
} else {
|
||||
R.string.mute_notifications
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
accountViewModel.toggleMutedPublicChat(channel.idHex)
|
||||
menuOpen = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -441,9 +499,6 @@ private fun RelayGroupRoomCompose(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val channelState by observeChannel(baseChannel, accountViewModel)
|
||||
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val author = lastMessage.author
|
||||
val noteEvent = lastMessage.event
|
||||
val lastContent =
|
||||
@@ -469,6 +524,54 @@ private fun RelayGroupRoomCompose(
|
||||
}
|
||||
}
|
||||
|
||||
RelayGroupRow(
|
||||
baseChannel = baseChannel,
|
||||
lastContent = lastContent,
|
||||
lastTime = lastMessage.createdAt(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
) { channel, dismiss ->
|
||||
// Long-press brings the group's membership actions to the Messages row itself, mirroring the
|
||||
// group top bar so "Remove from Messages" (drop from my list, stay a member) and "Leave"
|
||||
// (kind-9022) are reachable without opening the group first.
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.remove_from_messages)) },
|
||||
onClick = {
|
||||
dismiss()
|
||||
accountViewModel.removeRelayGroupFromMessages(channel)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) },
|
||||
onClick = {
|
||||
dismiss()
|
||||
accountViewModel.leaveRelayGroup(channel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One NIP-29 group as a Messages row: its picture, name, host-relay chip, and a caller-supplied
|
||||
* "last message" line and long-press menu.
|
||||
*
|
||||
* Everything except those two is fixed here, because every list that shows a group has to agree on it —
|
||||
* the picture fallback, the unread rule, and where a tap goes. A pending invite is a row in the same
|
||||
* sense a joined group is (see `ChannelInvitesSection`); it differs only in what its newest line says
|
||||
* and what you can do to it, which is exactly the two slots.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupRow(
|
||||
baseChannel: RelayGroupChannel,
|
||||
lastContent: String?,
|
||||
lastTime: Long?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
menuContent: @Composable ColumnScope.(channel: RelayGroupChannel, dismiss: () -> Unit) -> Unit,
|
||||
) {
|
||||
val channelState by observeChannel(baseChannel, accountViewModel)
|
||||
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val groupPicture = channel.profilePicture()?.ifBlank { null }
|
||||
val channelPicture =
|
||||
if (groupPicture != null) {
|
||||
@@ -485,9 +588,6 @@ private fun RelayGroupRoomCompose(
|
||||
// A placeholder row (no messages yet) has a null createdAt and never lights the dot.
|
||||
val lastReadTime by accountViewModel.account.loadLastReadFlow(relayGroupChannelLastReadRoute(channel.groupId)).collectAsStateWithLifecycle()
|
||||
|
||||
// Long-press brings the group's membership actions to the Messages row itself, mirroring the group
|
||||
// top bar so "Remove from Messages" (drop from my list, stay a member) and "Leave" (kind-9022) are
|
||||
// reachable without opening the group first.
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
@@ -510,9 +610,9 @@ private fun RelayGroupRoomCompose(
|
||||
)
|
||||
}
|
||||
},
|
||||
channelLastTime = lastMessage.createdAt(),
|
||||
channelLastTime = lastTime,
|
||||
channelLastContent = lastContent,
|
||||
hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime,
|
||||
hasNewMessages = (lastTime ?: Long.MIN_VALUE) > lastReadTime,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif =
|
||||
@@ -524,24 +624,75 @@ private fun RelayGroupRoomCompose(
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.remove_from_messages)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
accountViewModel.removeRelayGroupFromMessages(channel)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
accountViewModel.leaveRelayGroup(channel)
|
||||
},
|
||||
)
|
||||
menuContent(channel) { menuOpen = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending channel invite as a Messages row: the group it invites me to, with the invitation itself
|
||||
* as the row's newest line.
|
||||
*
|
||||
* New Requests is a list of rooms awaiting a decision and this is one, so it sorts in among the
|
||||
* unaccepted DMs by when the invite landed rather than being pinned above them. Deciding happens the
|
||||
* same way it does for a joined group: tap opens the channel so it can be read first (its top bar
|
||||
* offers "Add to Messages"), long-press brings the three answers to the row.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChannelInviteRoomCompose(
|
||||
inviteNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val workspaces = accountViewModel.account.buzzWorkspaces.flow.value
|
||||
val notice = remember(inviteNote, workspaces) { inviteNote.toMembershipNotice(workspaces) } ?: return
|
||||
val baseChannel =
|
||||
remember(notice) { LocalCache.getOrCreateRelayGroupChannel(GroupId(notice.channelId, notice.relay)) }
|
||||
|
||||
// The actor, not the signer: a kind-44100 is signed by the relay keypair reporting the membership
|
||||
// change it made, so naming its author here would put an npub on every invite.
|
||||
val actorName = observeUserNameByHex(notice.actor, accountViewModel)
|
||||
val lastContent =
|
||||
if (notice.actor != null) {
|
||||
stringRes(R.string.channel_invite_row_added_you_by, actorName)
|
||||
} else {
|
||||
stringRes(R.string.channel_invite_row_added_you)
|
||||
}
|
||||
|
||||
RelayGroupRow(
|
||||
baseChannel = baseChannel,
|
||||
lastContent = lastContent,
|
||||
lastTime = notice.createdAt,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
) { channel, dismiss ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.add_to_messages)) },
|
||||
onClick = {
|
||||
dismiss()
|
||||
accountViewModel.acceptChannelInvite(channel)
|
||||
},
|
||||
)
|
||||
// Ignore is a local display choice that leaves me in the roster; Leave is the kind-9022 that
|
||||
// actually removes me from the channel. Keeping both means "get this off my list" never has
|
||||
// to mean "announce to the relay that I left".
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.channel_invite_ignore)) },
|
||||
onClick = {
|
||||
dismiss()
|
||||
accountViewModel.dismissChannelInvite(channel.groupId.id)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.channel_invite_leave), color = MaterialTheme.colorScheme.error) },
|
||||
onClick = {
|
||||
dismiss()
|
||||
accountViewModel.leaveChannelInvite(channel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConcordRoomCompose(
|
||||
lastMessage: Note,
|
||||
@@ -753,6 +904,7 @@ private fun ChannelTitleWithLabelInfo(
|
||||
labelIcon: MaterialSymbol,
|
||||
label: Int,
|
||||
modifier: Modifier,
|
||||
labelContentDescription: String? = null,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Text(
|
||||
@@ -768,6 +920,7 @@ private fun ChannelTitleWithLabelInfo(
|
||||
symbol = labelIcon,
|
||||
text = stringRes(id = label),
|
||||
modifier = Modifier.widthIn(max = ChatLabelMaxWidth),
|
||||
contentDescription = labelContentDescription,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+66
-9
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.publicChatChannelIdOf
|
||||
import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
|
||||
@@ -36,8 +37,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServ
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote
|
||||
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
@@ -51,23 +56,71 @@ import kotlinx.coroutines.flow.map
|
||||
* silently skipped: their row could show a dot while the envelope stayed clean.
|
||||
*
|
||||
* Returns null when the row cannot be unread at all (no event, my own newest message in a DM, everyone
|
||||
* hidden), so callers can skip it rather than subscribe to a flow that is always false.
|
||||
* hidden), so callers can skip it rather than subscribe to a flow that is always false. A muted public
|
||||
* chat is deliberately NOT one of these cases: mute is a runtime-toggleable setting, not a structural
|
||||
* fact about the row, so it is folded into the emitted `Flow<Boolean>` (via [publicChatChannelIdOf] combined
|
||||
* with the mute set) instead of being resolved as a one-shot snapshot at construction time. Early-return
|
||||
* on a snapshot of the mute set would freeze the dot's mute state as of whenever the flow was built —
|
||||
* do not "simplify" this back into an early return.
|
||||
*
|
||||
* The two collapsed rows are why this returns a `Flow<Boolean>` rather than a `(route, createdAt)`
|
||||
* The two collapsed rows are why this carries a `Flow<Boolean>` rather than a `(route, createdAt)`
|
||||
* pair: their dot is a fan-in over every child channel, not one timestamp against one marker, and
|
||||
* approximating them by the newest child would miss an older channel that is still unread.
|
||||
*/
|
||||
fun rowHasUnreadFlow(
|
||||
class RowUnread(
|
||||
/**
|
||||
* The answer as of right now, for the caller's FIRST frame.
|
||||
*
|
||||
* A composable collecting [flow] only starts collecting after its first composition, so without
|
||||
* a seed every row would paint dotless and correct itself a frame later — a dot flash on every
|
||||
* recycled row while scrolling. Before the row was routed through this helper it read a
|
||||
* `StateFlow` directly and was right immediately; this keeps that property.
|
||||
*
|
||||
* `false` for the two collapsed rows, whose fan-in flows have no cheap synchronous answer. That
|
||||
* matches what those rows already did before this type existed.
|
||||
*/
|
||||
val initial: Boolean,
|
||||
val flow: Flow<Boolean>,
|
||||
)
|
||||
|
||||
fun rowHasUnread(
|
||||
row: Note,
|
||||
account: Account,
|
||||
): Flow<Boolean>? {
|
||||
): RowUnread? {
|
||||
// Collapsed rows own a fan-in flow across their children — reuse the row's own signal verbatim.
|
||||
if (row is RelayGroupServerRoomNote) return relayGroupServerHasUnreadFlow(account, row.relay)
|
||||
if (row is ConcordServerRoomNote) return concordCommunityHasUnreadFlow(account, row.communityId)
|
||||
if (row is RelayGroupServerRoomNote) return RowUnread(false, relayGroupServerHasUnreadFlow(account, row.relay))
|
||||
if (row is ConcordServerRoomNote) return RowUnread(false, concordCommunityHasUnreadFlow(account, row.communityId))
|
||||
|
||||
val route = rowLastReadRoute(row, account) ?: return null
|
||||
val createdAt = row.createdAt() ?: return null
|
||||
return account.settings.getLastReadFlow(route).map { lastReadAt -> createdAt > lastReadAt }
|
||||
|
||||
val lastRead = account.settings.getLastReadFlow(route)
|
||||
|
||||
// Public chats can be silenced at runtime, so the mute set has to be part of the
|
||||
// emitted signal rather than a snapshot taken when this flow was built — otherwise
|
||||
// toggling mute would not move the dot until something else re-keyed the caller.
|
||||
// Every other row type skips the mute flow entirely and stays a plain map.
|
||||
val mutedChannelId = publicChatChannelIdOf(row.event)
|
||||
|
||||
if (mutedChannelId == null) {
|
||||
return RowUnread(
|
||||
initial = createdAt > lastRead.value,
|
||||
flow = lastRead.map { createdAt > it }.distinctUntilChanged(),
|
||||
)
|
||||
}
|
||||
|
||||
val muted = account.settings.mutedPublicChats
|
||||
|
||||
// One expression feeds both the seed and the flow, so the first frame and every later
|
||||
// frame cannot disagree — the drift this helper exists to prevent, in miniature.
|
||||
val compute = { lastReadAt: Long, mutedSet: Set<String> ->
|
||||
createdAt > lastReadAt && mutedChannelId !in mutedSet
|
||||
}
|
||||
|
||||
return RowUnread(
|
||||
initial = compute(lastRead.value, muted.value),
|
||||
flow = combine(lastRead, muted) { read, mutedSet -> compute(read, mutedSet) }.distinctUntilChanged(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,8 +143,12 @@ private fun rowLastReadRoute(
|
||||
}
|
||||
|
||||
return when (val event = row.event) {
|
||||
// Same route strings the row composables use — see ChatroomHeaderCompose.
|
||||
is ChannelMessageEvent -> event.channelId()?.let { "Channel/$it" }
|
||||
// Same route strings the row composables use — see ChatroomHeaderCompose. The channel
|
||||
// id itself comes from [publicChatChannelIdOf], which is also what decides admin events
|
||||
// (ChannelHideMessageEvent/ChannelMuteUserEvent) are not room activity — listing the
|
||||
// three concrete types here keeps them falling through to `else`.
|
||||
is ChannelMessageEvent, is ChannelMetadataEvent, is ChannelCreateEvent ->
|
||||
publicChatChannelIdOf(event)?.let { "Channel/$it" }
|
||||
is EphemeralChatEvent -> event.roomId()?.let { "Channel/${it.toKey()}" }
|
||||
is GeohashChatEvent -> event.geohash()?.let { "Geohash/$it" }
|
||||
// DMs keep their own rule: a room whose newest message is mine counts as read.
|
||||
+3
-10
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.util.replace
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.publicChatChannelIdOf
|
||||
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
|
||||
@@ -45,8 +46,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
@@ -632,15 +631,9 @@ class ChatroomListKnownFeedFilter(
|
||||
|
||||
// Maps a note that represents a public chat row to its channel id. The
|
||||
// representative note for a channel may be the channel's create event
|
||||
// (id == channelId), a metadata update, or a message — match all three so
|
||||
// (id == channelId), a metadata update, or a message — all three resolve so
|
||||
// an arriving ChannelMessageEvent replaces an existing placeholder
|
||||
// metadata/create note for the same channel instead of duplicating it
|
||||
// (which would yield the same LazyColumn key twice).
|
||||
private fun publicChannelIdOf(note: Note): String? =
|
||||
when (val event = note.event) {
|
||||
is ChannelMessageEvent -> event.channelId()
|
||||
is ChannelMetadataEvent -> event.channelId()
|
||||
is ChannelCreateEvent -> event.id
|
||||
else -> null
|
||||
}
|
||||
private fun publicChannelIdOf(note: Note): String? = publicChatChannelIdOf(note.event)
|
||||
}
|
||||
|
||||
+22
-1
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal
|
||||
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
|
||||
import com.vitorpamplona.amethyst.commons.util.replace
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
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
|
||||
@@ -73,7 +74,27 @@ class ChatroomListNewFeedFilter(
|
||||
}
|
||||
}
|
||||
|
||||
return (privateMessages + marmotGroups).sortedByDefaultFeedOrder()
|
||||
// NIP-29 groups a relay says somebody added me to, but that I have not answered: not on my
|
||||
// kind-10009, not dismissed, no later kind-44101 withdrawing it. Exactly the same standing as
|
||||
// an unaccepted DM — a room waiting on a decision — so it belongs in this list and not pinned
|
||||
// above it, sorted by when the invite landed like everything else here.
|
||||
//
|
||||
// The row is the kind-44100 itself, which carries the channel (`h`), the actor and the time.
|
||||
// ChatroomHeaderCompose renders it as the group's row with the invitation as its newest line.
|
||||
val relayGroupInvites =
|
||||
if (!isEnabled(ChatFeedType.NIP29)) {
|
||||
emptyList()
|
||||
} else {
|
||||
// Read the map the invalidation is driven by, not the sorted list derived from it:
|
||||
// `AccountFeedContentStates` rebuilds this feed when `pendingByEventId` emits, and
|
||||
// `flow` is a second StateFlow mapped off that one, so at the instant the rebuild runs
|
||||
// it can still hold the previous answer — an accepted invite then keeps its row until
|
||||
// something else refreshes the list. (Order is irrelevant here; the feed sorts below.)
|
||||
account.channelInvites.pendingByEventId.value.keys
|
||||
.mapNotNull { LocalCache.getNoteIfExists(it) }
|
||||
}
|
||||
|
||||
return (privateMessages + marmotGroups + relayGroupInvites).sortedByDefaultFeedOrder()
|
||||
}
|
||||
|
||||
override fun updateListWith(
|
||||
|
||||
+2
-14
@@ -87,13 +87,6 @@ fun ChatroomListFeedView(
|
||||
scrollStateKey: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
/**
|
||||
* Pinned above the rows. Rendered INSIDE the feed rather than beside it so it inherits
|
||||
* [rememberFeedContentPadding] — the collapsing top bar draws over this area, and a header placed
|
||||
* outside the list lands underneath it. Shown in every state, so a standing prompt is still
|
||||
* reachable when the list itself is empty.
|
||||
*/
|
||||
headerContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
DisposableEffect(Unit) {
|
||||
Log.d("DMPagination") { "rooms.list: OPEN" }
|
||||
@@ -101,7 +94,7 @@ fun ChatroomListFeedView(
|
||||
}
|
||||
RefresheableBox(feedContentState, true) {
|
||||
SaveableFeedContentState(feedContentState, scrollStateKey) { listState ->
|
||||
CrossFadeState(feedContentState, listState, accountViewModel, nav, headerContent)
|
||||
CrossFadeState(feedContentState, listState, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,7 +105,6 @@ private fun CrossFadeState(
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
headerContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val feedState by feedContentState.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -142,7 +134,6 @@ private fun CrossFadeState(
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
Column(Modifier.padding(rememberFeedContentPadding(FeedPadding))) {
|
||||
headerContent?.invoke()
|
||||
if (historyExhausted) {
|
||||
FeedEmpty { feedContentState.invalidateData() }
|
||||
} else {
|
||||
@@ -156,7 +147,7 @@ private fun CrossFadeState(
|
||||
}
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
FeedLoaded(state, listState, accountViewModel, nav, headerContent)
|
||||
FeedLoaded(state, listState, accountViewModel, nav)
|
||||
}
|
||||
|
||||
FeedState.Loading -> {
|
||||
@@ -172,7 +163,6 @@ private fun FeedLoaded(
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
headerContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -228,8 +218,6 @@ private fun FeedLoaded(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
) {
|
||||
headerContent?.let { item("chatroom-list-header") { it() } }
|
||||
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
key = { _, item -> chatroomLazyKey(item, myPubKey) },
|
||||
|
||||
-12
@@ -49,7 +49,6 @@ import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInvitesSection
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
|
||||
@@ -132,17 +131,6 @@ fun MessagesPager(
|
||||
scrollStateKey = tabs[page].scrollStateKey,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
// Channels somebody added you to are pending decisions, exactly like an unaccepted DM — so
|
||||
// they belong on New Requests, pinned above the rows. Passed as the feed's header rather
|
||||
// than stacked beside it: the collapsing top bar draws over this area, so a header outside
|
||||
// the list renders underneath it. The Notifications tab shows the same prompts from the
|
||||
// same state holder, so the two surfaces cannot disagree.
|
||||
headerContent =
|
||||
if (tabs[page].resource == R.string.new_requests) {
|
||||
{ ChannelInvitesSection(accountViewModel, nav) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+28
-6
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
|
||||
@@ -39,9 +40,11 @@ import com.vitorpamplona.amethyst.service.BundledInsert
|
||||
import com.vitorpamplona.amethyst.service.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrderCard
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedOrderCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
@@ -135,7 +138,7 @@ class CardFeedContentState(
|
||||
val updatedCards =
|
||||
(oldNotesState.feed.value.list + newCards)
|
||||
.distinctBy { it.id() }
|
||||
.sortedWith(DefaultFeedOrderCard)
|
||||
.sortedWith(NotificationFeedOrderCard)
|
||||
.take(localFilter.limit())
|
||||
.toImmutableList()
|
||||
|
||||
@@ -149,7 +152,7 @@ class CardFeedContentState(
|
||||
|
||||
val cards =
|
||||
convertToCard(notes)
|
||||
.sortedWith(DefaultFeedOrderCard)
|
||||
.sortedWith(NotificationFeedOrderCard)
|
||||
.take(localFilter.limit())
|
||||
.toImmutableList()
|
||||
|
||||
@@ -366,7 +369,10 @@ class CardFeedContentState(
|
||||
// card — a duplicate of the grouped one.
|
||||
it.event !is NutzapEvent
|
||||
}.map {
|
||||
if (it.event is PrivateDmEvent || it.event is NIP17Group || it.isInMarmotGroup()) {
|
||||
val pendingInvite = if (it.event is MemberAddedNotificationEvent) pendingInvites[it.idHex] else null
|
||||
if (pendingInvite != null) {
|
||||
ChannelInviteCard(it, pendingInvite)
|
||||
} else if (it.event is PrivateDmEvent || it.event is NIP17Group || it.isInMarmotGroup()) {
|
||||
MessageSetCard(it)
|
||||
} else if (it.event is BadgeAwardEvent) {
|
||||
BadgeCard(it)
|
||||
@@ -376,9 +382,25 @@ class CardFeedContentState(
|
||||
}
|
||||
|
||||
return (multiCards + textNoteCards + userZaps + userNutzaps)
|
||||
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
|
||||
.sortedWith(NotificationFeedOrderCard)
|
||||
}
|
||||
|
||||
/**
|
||||
* The unanswered channel invites, keyed by their kind-44100.
|
||||
*
|
||||
* A StateFlow read, so each access is a volatile load of an already-built map — cheap enough to
|
||||
* touch per note. `acceptableEvent` has already narrowed the feed to pending ones, so this only has
|
||||
* to attach the resolved invite to its row; a 44100 that is no longer pending falls through to an
|
||||
* ordinary card, which the filter should have excluded anyway.
|
||||
*/
|
||||
private val pendingInvites: Map<HexKey, BuzzChannelInvite>
|
||||
get() =
|
||||
(localFilter as? NotificationFeedFilter)
|
||||
?.account
|
||||
?.channelInvites
|
||||
?.pendingByEventId
|
||||
?.value ?: emptyMap()
|
||||
|
||||
private fun updateFeed(notes: ImmutableList<Card>) {
|
||||
if (notes.size >= localFilter.limit()) {
|
||||
val lastNoteTime =
|
||||
@@ -458,7 +480,7 @@ class CardFeedContentState(
|
||||
val updatedCards =
|
||||
(oldNotesState.feed.value.list + newCards)
|
||||
.distinctBy { it.id() }
|
||||
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
|
||||
.sortedWith(NotificationFeedOrderCard)
|
||||
.take(localFilter.limit())
|
||||
.toImmutableList()
|
||||
|
||||
|
||||
+23
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
|
||||
import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -129,6 +130,24 @@ class MessageSetCard(
|
||||
override fun id() = note.idHex
|
||||
}
|
||||
|
||||
/**
|
||||
* "Somebody added you to a channel" — a relay-signed kind-44100 that is still unanswered, carrying the
|
||||
* [invite] the projection resolved it to (who did it, which channel, on which relay).
|
||||
*
|
||||
* A question rather than a dated event, so [com.vitorpamplona.amethyst.ui.dal.NotificationFeedOrderCard]
|
||||
* sorts these ahead of everything else instead of letting an old one sink into history. It still holds
|
||||
* its [note], so it dedups, scrolls-to and pages exactly like every other card.
|
||||
*/
|
||||
@Immutable
|
||||
class ChannelInviteCard(
|
||||
val note: Note,
|
||||
val invite: BuzzChannelInvite,
|
||||
) : Card {
|
||||
override fun createdAt(): Long = invite.createdAt
|
||||
|
||||
override fun id() = note.idHex
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this card contains a specific event ID.
|
||||
* Used for scrolling to a notification from a push notification intent.
|
||||
@@ -147,6 +166,10 @@ fun Card.containsEventId(eventId: String): Boolean =
|
||||
note.idHex == eventId
|
||||
}
|
||||
|
||||
is ChannelInviteCard -> {
|
||||
note.idHex == eventId
|
||||
}
|
||||
|
||||
is ZapUserSetCard -> {
|
||||
zapEvents.any { it.response.idHex == eventId || it.request.idHex == eventId }
|
||||
}
|
||||
|
||||
+50
-3
@@ -115,11 +115,11 @@ fun RenderCardFeed(
|
||||
// LazyColumns then share the same listState and only the top one scrolls.
|
||||
when (val state = feedState) {
|
||||
is CardFeedState.Empty -> {
|
||||
NotificationFeedEmpty(feedContent::invalidateData)
|
||||
HeaderAbove(headerContent) { NotificationFeedEmpty(feedContent::invalidateData) }
|
||||
}
|
||||
|
||||
is CardFeedState.FeedError -> {
|
||||
FeedError(state.errorMessage, feedContent::invalidateData)
|
||||
HeaderAbove(headerContent) { FeedError(state.errorMessage, feedContent::invalidateData) }
|
||||
}
|
||||
|
||||
is CardFeedState.Loaded -> {
|
||||
@@ -137,7 +137,37 @@ fun RenderCardFeed(
|
||||
}
|
||||
|
||||
CardFeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
HeaderAbove(headerContent) { LoadingFeed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws [headerContent] above a non-loaded feed state.
|
||||
*
|
||||
* The header is not part of the feed's contents — it carries standing prompts (a pending channel
|
||||
* invite, "you have no inbox relay") that are true regardless of whether any notification has loaded.
|
||||
* Drawing it only in the `Loaded` branch made it blink out and back on every visit, because arriving on
|
||||
* the screen re-runs `checkKeysInvalidateDataAndSendToTop` and any refresh that momentarily computes an
|
||||
* empty list flips the state through `Empty`/`Loading` (see the `CardFeedState` comment above). Worse
|
||||
* for the relay prompt specifically: a missing inbox relay is the most likely *reason* the feed is
|
||||
* empty, so the one state that hid it was the one that needed it.
|
||||
*
|
||||
* The padding is applied here because only the `Loaded` branch has a `LazyColumn` to carry the
|
||||
* scaffold's inset as content padding; without it the header would draw under the disappearing top bar.
|
||||
* Same shape [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.ChatroomListFeedView] uses.
|
||||
*/
|
||||
@Composable
|
||||
private fun HeaderAbove(
|
||||
headerContent: (@Composable () -> Unit)?,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (headerContent == null) {
|
||||
content()
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize().padding(rememberFeedContentPadding(FeedPadding))) {
|
||||
headerContent()
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,6 +399,23 @@ private fun RenderCardItem(
|
||||
)
|
||||
}
|
||||
|
||||
is ChannelInviteCard -> {
|
||||
// The same NoteCompose every other row uses. The invite-specific part is only the body,
|
||||
// dispatched by kind in RenderNoteRow — so the author header, 3-dot menu, reactions row,
|
||||
// last-read background and click-through are the shared ones rather than re-implemented.
|
||||
NoteCompose(
|
||||
baseNote = item.note,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
routeForLastRead = routeForLastRead,
|
||||
isBoostedNote = false,
|
||||
isQuotedNote = false,
|
||||
isHiddenFeed = showHidden,
|
||||
quotesLeft = 3,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
is MessageSetCard -> {
|
||||
MessageSetCompose(
|
||||
messageSetCard = item,
|
||||
|
||||
-212
@@ -1,212 +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.amethyst.ui.screen.loggedIn.notifications
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* "Somebody added you to a channel" prompts, rendered above the Notifications feed (the same header slot
|
||||
* the missing-inbox-relay prompt uses) and inside Messages › New Requests.
|
||||
*
|
||||
* These are deliberately NOT auto-accepted. On a Buzz relay another member can add you to a channel
|
||||
* server-side: the relay writes you into the kind-39002 roster and you can immediately read and post,
|
||||
* without you ever agreeing to see it. Amethyst used to silently subscribe to those channels' messages
|
||||
* while showing no row for them anywhere, so a channel could be joined, streaming, and invisible at once.
|
||||
* Now the relay's decision is surfaced as a question instead of being acted on.
|
||||
*/
|
||||
@Composable
|
||||
fun ChannelInvitesSection(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val invites by accountViewModel.feedStates.channelInvites.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
if (invites.isEmpty()) return
|
||||
|
||||
Column(modifier) {
|
||||
invites.forEach { invite ->
|
||||
// Keyed by channel: the list is sorted newest-first, so an arriving invite shifts every row
|
||||
// below it. Without a key Compose matches children by position and each shifted row would
|
||||
// recompose against a different invite — re-resolving the actor and reloading their avatar.
|
||||
key(invite.channelId) {
|
||||
ChannelInviteCard(invite, accountViewModel, nav)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One pending add, drawn as a feed row instead of a floating Material card: the actor is the row's
|
||||
* author — picture, name and time in the usual note header — and "added you to X" is the row's content,
|
||||
* so the prompt reads like the reply/mention notifications it sits next to. The three choices take the
|
||||
* reactions slot, which spans the full width and therefore fits "Add to Messages" without wrapping.
|
||||
*/
|
||||
@Composable
|
||||
fun ChannelInviteCard(
|
||||
invite: BuzzChannelInvite,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val baseChannel =
|
||||
remember(invite.channelId, invite.relay) {
|
||||
LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay))
|
||||
}
|
||||
|
||||
// The channel's own metadata flow, collected directly rather than through `observeChannel`. That
|
||||
// helper also registers a ChannelFinder query, and every assembler under it is gated on
|
||||
// `is PublicChatChannel` / `is LiveActivitiesChannel` — a RelayGroupChannel yields no filter at all,
|
||||
// so the registration buys nothing and only churns the app-wide key set on mount/unmount. The flow
|
||||
// still fills the name in when the group's kind-39000 lands from the directory subscription, and
|
||||
// nothing here opens the channel's *message* subscription — holding that back until the viewer
|
||||
// answers is the whole point of the prompt.
|
||||
val channelState by
|
||||
remember(baseChannel) { baseChannel.flow().metadata.stateFlow }
|
||||
.collectAsStateWithLifecycle()
|
||||
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val actorUser = remember(invite.actor) { invite.actor?.let { LocalCache.getOrCreateUser(it) } }
|
||||
|
||||
// A pending invite is by definition unanswered, so it always carries the new-item wash rather than
|
||||
// fading with a last-read marker: it is a standing question, not a dated event.
|
||||
val backgroundColor =
|
||||
MaterialTheme.colorScheme.newItemBackgroundColor
|
||||
.compositeOver(MaterialTheme.colorScheme.background)
|
||||
|
||||
NoteComposeLayout(
|
||||
modifier =
|
||||
remember(backgroundColor) {
|
||||
Modifier.drawBehind { drawRect(backgroundColor) }.fillMaxWidth()
|
||||
},
|
||||
authorPicture = {
|
||||
Box(Size55Modifier, contentAlignment = Alignment.BottomEnd) {
|
||||
if (actorUser != null) {
|
||||
UserPicture(actorUser, Size55dp, accountViewModel = accountViewModel, nav = nav)
|
||||
} else {
|
||||
DisplayBlankAuthor(Size55dp, accountViewModel = accountViewModel)
|
||||
}
|
||||
}
|
||||
},
|
||||
firstRow = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
modifier = UserNameRowHeight,
|
||||
) {
|
||||
// Who did it matters: the relay reports a self-join with the same event, so naming the
|
||||
// actor is what tells "I joined this" apart from "a stranger put me here".
|
||||
if (actorUser != null) {
|
||||
UsernameDisplay(actorUser, Modifier.weight(1f), accountViewModel = accountViewModel)
|
||||
} else {
|
||||
Text(
|
||||
text = stringRes(R.string.channel_invite_unknown_actor),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
// DottedTight, not Dotted: the row's `spacedBy` already supplies the gap, so the
|
||||
// dotted variant's own leading space would double it. Same choice the note header makes.
|
||||
TimeAgo(invite.createdAt, style = TimeAgoStyle.DottedTight)
|
||||
}
|
||||
},
|
||||
secondRow = {},
|
||||
noteContent = {
|
||||
Text(text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()))
|
||||
|
||||
Text(
|
||||
text = invite.relay.displayUrl(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
reactionsRow = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size10dp),
|
||||
) {
|
||||
// Leave is separate from Ignore on purpose: Ignore is a local display choice that leaves
|
||||
// you in the roster, Leave is the kind-9022 that actually removes you from the channel.
|
||||
TextButton(onClick = { accountViewModel.leaveChannelInvite(channel) }) {
|
||||
Text(stringRes(R.string.channel_invite_leave), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
TextButton(onClick = { accountViewModel.dismissChannelInvite(invite.channelId) }) {
|
||||
Text(stringRes(R.string.channel_invite_ignore))
|
||||
}
|
||||
// Accepting *is* `addRelayGroupToMessages`, the same call behind the channel top bar's
|
||||
// "Add to Messages", so it carries that label rather than a second word for one action.
|
||||
TextButton(onClick = { accountViewModel.acceptChannelInvite(channel) }) {
|
||||
Text(stringRes(R.string.add_to_messages), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
-64
@@ -1,64 +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.amethyst.ui.screen.loggedIn.notifications
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* The channels somebody else added the viewer to that are still awaiting a decision.
|
||||
*
|
||||
* An entry drops out the moment it stops being a question: accepting writes the group into kind-10009
|
||||
* (so `joined` covers it and the ordinary Messages row takes over), dismissing records the channel in
|
||||
* `dismissedChannelInvites`, and leaving makes the relay withdraw the membership. Nothing here asserts
|
||||
* membership — the relay already granted that — it only tracks whose call it is to surface the channel.
|
||||
*
|
||||
* Modelled on [OpenPollsState]: a small always-on projection the Notifications screen and the Messages
|
||||
* "New Requests" tab both render, so the two surfaces can never disagree about what is pending.
|
||||
*/
|
||||
@Stable
|
||||
class ChannelInvitesState(
|
||||
private val account: Account,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
val flow: StateFlow<List<BuzzChannelInvite>> =
|
||||
combine(
|
||||
BuzzChannelInvites.flow.map { it[account.userProfile().pubkeyHex] ?: emptyMap() },
|
||||
account.settings.dismissedChannelInvites,
|
||||
account.relayGroupList.liveRelayGroupList,
|
||||
) { invites, dismissed, joined ->
|
||||
val joinedIds = joined.mapTo(HashSet()) { it.groupId }
|
||||
invites.values
|
||||
.filter { it.channelId !in dismissed && it.channelId !in joinedIds }
|
||||
.sortedByDescending { it.createdAt }
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
}
|
||||
+3
-3
@@ -242,11 +242,11 @@ internal fun SingleNotificationsBody(
|
||||
nav = nav,
|
||||
routeForLastRead = NOTIFICATION_LAST_READ_KEY,
|
||||
scrollToEventId = scrollToEventId,
|
||||
// "X added you to #channel" is not a header prompt any more — each pending invite is a
|
||||
// ChannelInviteCard in the feed itself, sorted ahead of the dated rows by
|
||||
// NotificationFeedOrderCard.
|
||||
headerContent = {
|
||||
ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav)
|
||||
// "X added you to #channel" prompts sit above the feed rather than inside it: they are a
|
||||
// standing decision, not a dated event, so they must not scroll away into history.
|
||||
ChannelInvitesSection(accountViewModel, nav)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+37
-2
@@ -28,12 +28,14 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.amethyst.model.isMutedPublicChatMessage
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
|
||||
@@ -190,8 +192,15 @@ class NotificationFeedFilter(
|
||||
VoiceEvent.KIND,
|
||||
VoiceReplyEvent.KIND,
|
||||
// A Buzz workflow approval gate (46010) addressed to me — I need to grant/deny it.
|
||||
// Also gates the push dispatcher, which uses NOTIFICATION_KINDS as its first filter.
|
||||
// NOTE: this list does NOT gate push. NotificationDispatcher declares its own, separate
|
||||
// NOTIFICATION_KINDS; adding a kind here changes only what renders on the tab.
|
||||
WorkflowApprovalRequestedEvent.KIND,
|
||||
// "Somebody added you to a channel" (44100). Like the approval gate above, this is a
|
||||
// question addressed to me rather than a dated event — it renders as a ChannelInviteCard
|
||||
// with Leave / Ignore / Add to Messages, and the DAL sorts pending ones to the top so an
|
||||
// old invite can't sink into history. `acceptableEvent` narrows this to the ones still
|
||||
// unanswered; a self-join, a dismissal or an accept drops it.
|
||||
MemberAddedNotificationEvent.KIND,
|
||||
) + ADDRESSABLE_KINDS
|
||||
|
||||
// How deep to walk a public chat reply chain looking for one of the
|
||||
@@ -488,6 +497,27 @@ class NotificationFeedFilter(
|
||||
|
||||
val noteEvent = it.event
|
||||
|
||||
// Muted public chats contribute nothing to Notifications.
|
||||
//
|
||||
// NOTE: this filter is one-way. NotificationFeedFilter is an AdditiveFeedFilter, so
|
||||
// applyFilter only ever runs over newly-arriving items — nothing re-scans LocalCache
|
||||
// when the mute set changes. Entries suppressed while muted therefore do NOT come
|
||||
// back on unmute until the tab is refreshed. Device-confirmed; see the design doc.
|
||||
if (isMutedPublicChatMessage(noteEvent, account.settings.mutedPublicChats.value)) return false
|
||||
|
||||
// "Somebody added you to a channel" (kind 44100). The relay keypair authors it, so none of the
|
||||
// follow/relevance heuristics below can say anything useful about it — its relevance is that it
|
||||
// is addressed to me and still unanswered. Every rule that decides "unanswered" (self-join,
|
||||
// dismissal, already on my kind-10009, DM vs named channel, superseded by a kind-44101) lives in
|
||||
// the account's projection, so this is a map lookup, and answering the prompt drops the row on
|
||||
// the next invalidation. This is a standing decision, not a chat message: it ignores the
|
||||
// Messages toggle.
|
||||
//
|
||||
// Ordered after the mute check only for readability — a blanket "never show this" reads before a
|
||||
// kind-specific accept. The two can't actually interact: the mute rule matches public-chat
|
||||
// messages, which a kind-44100 is not.
|
||||
if (noteEvent is MemberAddedNotificationEvent) return account.channelInvites.isPending(it.idHex)
|
||||
|
||||
// Buzz DM: a group chat message in a `t=dm` channel whose 39000 participants include me. A Buzz
|
||||
// relay carries DM messages as either kind-9 (NIP-29 chat) or kind-40002 (stream message v2), and
|
||||
// neither `p`-tags the recipient, so being a participant of the DM channel is the relevance signal
|
||||
@@ -551,7 +581,12 @@ class NotificationFeedFilter(
|
||||
noteEvent is RepostEvent || noteEvent is GenericRepostEvent
|
||||
) {
|
||||
val target = it.replyTo?.lastOrNull()
|
||||
if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) {
|
||||
if (target != null &&
|
||||
(
|
||||
account.isThreadMuted(account.resolveThreadRoot(target)) ||
|
||||
isMutedPublicChatMessage(target.event, account.settings.mutedPublicChats.value)
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -260,7 +260,7 @@ fun RelayAuthSettingsScreen(
|
||||
) {
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Dns,
|
||||
title = R.string.relay_auth_auto_my_relays,
|
||||
title = R.string.relay_auth_auto_my_relays_and_venues,
|
||||
checked = myRelays,
|
||||
onCheckedChange = { account.settings.changeRelayAuthTrustMyRelaysAndVenues(it) },
|
||||
)
|
||||
|
||||
+98
@@ -32,7 +32,9 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -64,6 +66,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
@@ -74,6 +77,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
|
||||
@@ -82,6 +86,7 @@ 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.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletHistoryEoseManager
|
||||
import com.vitorpamplona.amethyst.ui.components.util.getText
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -96,6 +101,9 @@ import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.SpendingDirection
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.DateFormat
|
||||
import java.text.NumberFormat
|
||||
@@ -382,7 +390,19 @@ private fun CashuWalletContent(
|
||||
onMoveCoins: (String) -> Unit,
|
||||
onResumePendingQuote: () -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// The kind:7376 rows below are only ever as complete as the relays were asked to be. The live wallet
|
||||
// subscription asks for six kinds in one uncapped REQ, and history is the most numerous of them, so
|
||||
// what lands there is a recent-N suffix chosen by each relay's cap. This pager walks older pages on
|
||||
// demand — see CashuWalletHistoryEoseManager.
|
||||
val history7376 = remember(accountViewModel) { accountViewModel.dataSources().account.cashuWalletHistory }
|
||||
val loadingOlder by history7376.loadingMore.collectAsStateWithLifecycle()
|
||||
val pagingStatus by history7376.status.collectAsStateWithLifecycle()
|
||||
CashuHistoryPaging(historyCount = { history.size }, listState = listState, history = history7376)
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
@@ -447,12 +467,90 @@ private fun CashuWalletContent(
|
||||
items(history, key = { it.id }) { entry ->
|
||||
HistoryRow(entry, accountViewModel, nav)
|
||||
}
|
||||
item {
|
||||
CashuHistoryFooter(
|
||||
loadingOlder = loadingOlder,
|
||||
exhausted = pagingStatus.exhausted,
|
||||
stalledCount = pagingStatus.stalledCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** How many history rows to pull in before the user has scrolled at all. */
|
||||
private const val CASHU_HISTORY_TARGET = 30
|
||||
|
||||
/** How close to the end of the list a page request fires. */
|
||||
private const val CASHU_HISTORY_PREFETCH_AHEAD = 5
|
||||
|
||||
/**
|
||||
* Drives the spending-history backward pager: pull a page on open so the list isn't whatever suffix the
|
||||
* relay caps returned, then page older rows as the list nears its end. Same two-driver shape the NIP-29
|
||||
* thread list and the notifications feed use — a bootstrap that fills the first screen, and a look-ahead
|
||||
* that keeps going only while the user is actually scrolling toward the bottom.
|
||||
*/
|
||||
@Composable
|
||||
private fun CashuHistoryPaging(
|
||||
historyCount: () -> Int,
|
||||
listState: LazyListState,
|
||||
history: CashuWalletHistoryEoseManager,
|
||||
) {
|
||||
LaunchedEffect(history) {
|
||||
combine(snapshotFlow { historyCount() }, history.loadingMore, history.status) { count, loading, s ->
|
||||
count < CASHU_HISTORY_TARGET && !loading && !s.exhausted
|
||||
}.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { history.advanceAll() }
|
||||
}
|
||||
LaunchedEffect(history, listState) {
|
||||
snapshotFlow {
|
||||
val last =
|
||||
listState.layoutInfo.visibleItemsInfo
|
||||
.lastOrNull()
|
||||
?.index ?: 0
|
||||
val total = historyCount()
|
||||
total > 0 && last >= total - CASHU_HISTORY_PREFETCH_AHEAD
|
||||
}.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect {
|
||||
if (!history.status.value.exhausted && !history.loadingMore.value) history.advanceAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A quiet footer under the transaction list: what the pager is doing, or nothing when idle.
|
||||
*
|
||||
* Splits on [stalledCount] because `exhausted` means "nothing more reachable right now", not "caught
|
||||
* up" — a relay that answered an auth CLOSE, is unreachable, or went silent is stalled rather than done,
|
||||
* and claiming the history is complete while some of it was never served would be a lie about the user's
|
||||
* own money.
|
||||
*/
|
||||
@Composable
|
||||
private fun CashuHistoryFooter(
|
||||
loadingOlder: Boolean,
|
||||
exhausted: Boolean,
|
||||
stalledCount: Int,
|
||||
) {
|
||||
val text =
|
||||
when {
|
||||
loadingOlder -> stringRes(R.string.cashu_history_loading_older)
|
||||
exhausted && stalledCount > 0 -> stringRes(R.string.cashu_history_some_relays_unreachable)
|
||||
exhausted -> stringRes(R.string.cashu_history_all_loaded)
|
||||
else -> return
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner that surfaces unfinished mint quotes — tappable to resume the
|
||||
* receive flow with the stored invoice. Driven by
|
||||
|
||||
+20
-6
@@ -216,16 +216,30 @@ class CashuWalletViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile every mint we hold tokens at against its NUT-07 `/checkstate`
|
||||
* — not just the mint a spend targets. Wired to the wallet screen opening
|
||||
* so a balance auto-redeemed from a mint we never configured (e.g. a
|
||||
* nutzap on a mint not in our kind:10019) still gets its stale proofs
|
||||
* swept. Safe to call repeatedly; no-ops when nothing is stale or the
|
||||
* wallet hasn't started yet.
|
||||
* Bring the wallet's view of its own money up to date, in the two ways it
|
||||
* can be behind.
|
||||
*
|
||||
* First re-page the proof set off the relays: the live subscription takes
|
||||
* whatever one uncapped REQ returns, so proofs older than the relay's cap
|
||||
* are simply absent, and the balance quietly reads low (see
|
||||
* [CashuWalletState.resyncProofsFromRelays]). `force` because the user
|
||||
* opening the wallet is a direct request for a current number, and the
|
||||
* startup walk may have run before the relay list was known.
|
||||
*
|
||||
* Then reconcile every mint we hold tokens at against its NUT-07
|
||||
* `/checkstate` — not just the mint a spend targets — so a balance
|
||||
* auto-redeemed from a mint we never configured (e.g. a nutzap on a mint
|
||||
* not in our kind:10019) still gets its stale proofs swept. Safe to call
|
||||
* repeatedly; no-ops when nothing is stale or the wallet hasn't started.
|
||||
*/
|
||||
fun refresh() {
|
||||
val vm = accountViewModel ?: return
|
||||
vm.launchSigner {
|
||||
try {
|
||||
state.resyncProofsFromRelays(force = true)
|
||||
} catch (e: Exception) {
|
||||
Log.w("CashuWallet", "wallet proof re-page failed", e)
|
||||
}
|
||||
try {
|
||||
state.syncAllMints()
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -1170,7 +1170,6 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Přihlásit se bez ptaní, když…</string>
|
||||
<string name="relay_auth_auto_my_relays">…jde o mé relé nebo o místnost, do které jsem vstoupil</string>
|
||||
<string name="relay_auth_auto_read_follows">…čtu někoho, koho sleduji</string>
|
||||
<string name="relay_auth_auto_message_follows">…píšu někomu, koho sleduji</string>
|
||||
<string name="relay_auth_auto_message_strangers">…píšu komukoli jinému</string>
|
||||
|
||||
@@ -1110,7 +1110,6 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Ohne Nachfrage anmelden, wenn…</string>
|
||||
<string name="relay_auth_auto_my_relays">…es mein Relay ist oder ein Raum, dem ich beigetreten bin</string>
|
||||
<string name="relay_auth_auto_read_follows">…ich jemanden lese, dem ich folge</string>
|
||||
<string name="relay_auth_auto_message_follows">…ich jemandem schreibe, dem ich folge</string>
|
||||
<string name="relay_auth_auto_message_strangers">…ich jemand anderem schreibe</string>
|
||||
|
||||
@@ -1110,7 +1110,6 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">कब पूछे बिना प्रवेशांकन करें\u2026</string>
|
||||
<string name="relay_auth_auto_my_relays">\u2026यह मेरा पुनःप्रसारक है। अथवा एक शाला जिससे मैं जुड चुका</string>
|
||||
<string name="relay_auth_auto_read_follows">\u2026मैं पढ रहा हूँ किसी को जिसका मैं अनुगमन करता हूँ</string>
|
||||
<string name="relay_auth_auto_message_follows">\u2026मैं सन्देश भेज रहा हूँ किसी को जिसका मैं अनुगमन करता हूँ</string>
|
||||
<string name="relay_auth_auto_message_strangers">\u2026मैं किसी अन्य को सन्देश भेज रहा हूँ</string>
|
||||
|
||||
@@ -1111,7 +1111,6 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Jelentkezzen be kérdés nélkül, ha\u2026</string>
|
||||
<string name="relay_auth_auto_my_relays">\u2026ez a saját átjátszóm, vagy egy szoba, amihez csatlakozott</string>
|
||||
<string name="relay_auth_auto_read_follows">\u2026olyan valakit olvasok, akit követek</string>
|
||||
<string name="relay_auth_auto_message_follows">\u2026olyan valakinek írok, akit követek</string>
|
||||
<string name="relay_auth_auto_message_strangers">\u2026bárki másnak írok</string>
|
||||
|
||||
@@ -591,6 +591,9 @@
|
||||
<string name="quick_action_block">Zablokuj</string>
|
||||
<string name="quick_action_mute_thread">Zablokuj wątek</string>
|
||||
<string name="quick_action_unmute_thread">Odblokuj wątek</string>
|
||||
<string name="mute_notifications">Wycisz powiadomienia</string>
|
||||
<string name="unmute_notifications">Wyłącz wyciszenie powiadomień</string>
|
||||
<string name="muted_chat_content_description">Powiadomienia są wyciszone dla tego czatu</string>
|
||||
<string name="quick_action_report">Zgłoś</string>
|
||||
<string name="quick_action_dont_show_again_button">Nie pokazuj więcej</string>
|
||||
<string name="report_dialog_spam">Spam lub oszustwa</string>
|
||||
@@ -1171,7 +1174,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Zaloguj się bez pytania, kiedy\u2026</string>
|
||||
<string name="relay_auth_auto_my_relays">\u2026to mój transmiter lub pokój, do którego dołączyłem</string>
|
||||
<string name="relay_auth_auto_read_follows">Czytam wpis osoby, którą obserwuję</string>
|
||||
<string name="relay_auth_auto_message_follows">\u2026wysyłam wiadomość do osoby, którą obserwuję</string>
|
||||
<string name="relay_auth_auto_message_strangers">\u2026piszę do wszystkich pozostałych</string>
|
||||
@@ -3094,6 +3096,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="cashu_remove_mint">Usuń Minta</string>
|
||||
<string name="cashu_add_mint">Dodaj mint</string>
|
||||
<string name="cashu_history">Historia</string>
|
||||
<string name="cashu_history_loading_older">Ładowanie starszych transakcji…</string>
|
||||
<string name="cashu_history_all_loaded">Brak starszych transakcji</string>
|
||||
<string name="cashu_history_some_relays_unreachable">Brak starszych transakcji z transmiterów, które odpowiedziały — z niektórymi nie udało się nawiązać połączenia</string>
|
||||
<string name="cashu_wallet_autosaves">Twój portfel zapisuje dane automatycznie w miarę dodawania lub usuwania mintów. Klucz Nutzap jest generowany automatycznie przy pierwszym dodaniu minta.</string>
|
||||
<string name="cashu_wallet_saving">Zapisywanie…</string>
|
||||
<string name="cashu_p2pk_section">Klucz Nutzap (zaawansowany)</string>
|
||||
|
||||
@@ -1108,7 +1108,6 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Entrar sem perguntar quando…</string>
|
||||
<string name="relay_auth_auto_my_relays">…for o meu relay, ou uma sala em que entrei</string>
|
||||
<string name="relay_auth_auto_read_follows">…eu estiver lendo alguém que sigo</string>
|
||||
<string name="relay_auth_auto_message_follows">…eu estiver enviando mensagem para alguém que sigo</string>
|
||||
<string name="relay_auth_auto_message_strangers">…eu estiver enviando mensagem para qualquer outra pessoa</string>
|
||||
|
||||
@@ -1108,7 +1108,6 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Logga in utan att fråga när…</string>
|
||||
<string name="relay_auth_auto_my_relays">…det är mitt relä, eller ett rum jag gått med i</string>
|
||||
<string name="relay_auth_auto_read_follows">…jag läser någon jag följer</string>
|
||||
<string name="relay_auth_auto_message_follows">…jag skriver till någon jag följer</string>
|
||||
<string name="relay_auth_auto_message_strangers">…jag skriver till någon annan</string>
|
||||
|
||||
@@ -602,6 +602,9 @@
|
||||
<string name="quick_action_block">Block</string>
|
||||
<string name="quick_action_mute_thread">Mute thread</string>
|
||||
<string name="quick_action_unmute_thread">Unmute thread</string>
|
||||
<string name="mute_notifications">Mute notifications</string>
|
||||
<string name="unmute_notifications">Unmute notifications</string>
|
||||
<string name="muted_chat_content_description">Notifications are muted for this chat</string>
|
||||
<string name="quick_action_report">Report</string>
|
||||
<string name="quick_action_dont_show_again_button">Don\'t show again</string>
|
||||
<string name="report_dialog_spam">Spam or scams</string>
|
||||
@@ -1149,7 +1152,7 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Log in without asking when\u2026</string>
|
||||
<string name="relay_auth_auto_my_relays">\u2026it\'s my relay, or a room I joined</string>
|
||||
<string name="relay_auth_auto_my_relays_and_venues">\u2026it\'s my relay, or a room I joined or follow</string>
|
||||
<string name="relay_auth_auto_read_follows">\u2026I\'m reading someone I follow</string>
|
||||
<string name="relay_auth_auto_message_follows">\u2026I\'m messaging someone I follow</string>
|
||||
<string name="relay_auth_auto_message_strangers">\u2026I\'m messaging anyone else</string>
|
||||
@@ -2889,6 +2892,9 @@
|
||||
<string name="relay_group_no_messages_yet">No messages yet</string>
|
||||
<string name="channel_invite_title">Added to %1$s</string>
|
||||
<string name="channel_invite_unknown_actor">Someone</string>
|
||||
<string name="channel_invite_added_you">added you to this channel</string>
|
||||
<string name="channel_invite_row_added_you_by">%1$s added you to this channel</string>
|
||||
<string name="channel_invite_row_added_you">You were added to this channel</string>
|
||||
<string name="channel_invite_ignore">Ignore</string>
|
||||
<string name="channel_invite_leave">Leave</string>
|
||||
<string name="relay_group_join_to_post">Join this group to send messages.</string>
|
||||
@@ -3259,6 +3265,9 @@
|
||||
<string name="cashu_remove_mint">Remove mint</string>
|
||||
<string name="cashu_add_mint">Add mint</string>
|
||||
<string name="cashu_history">History</string>
|
||||
<string name="cashu_history_loading_older">Loading older transactions…</string>
|
||||
<string name="cashu_history_all_loaded">No older transactions</string>
|
||||
<string name="cashu_history_some_relays_unreachable">No older transactions from the relays that answered — some could not be reached</string>
|
||||
<string name="cashu_wallet_autosaves">Your wallet saves automatically as you add or remove mints. A nutzap key is created for you the first time you add a mint.</string>
|
||||
<string name="cashu_wallet_saving">Saving…</string>
|
||||
<string name="cashu_p2pk_section">Nutzap key (advanced)</string>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Muted public chats ride inside the NIP-78 AppSpecificData blob so they reach the
|
||||
* user's other devices.
|
||||
*
|
||||
* The nullable default is the load-bearing part. `updateFrom` overwrites local state
|
||||
* whenever remote differs, and AppSpecificState replays the cached backup event on
|
||||
* every app start — so if an older client rewrote the blob and dropped the key, a
|
||||
* non-null `emptyList()` default would clear the local mute set on every single
|
||||
* launch. `null` keeps "key absent" distinguishable from "explicitly empty".
|
||||
*/
|
||||
class MutedPublicChatsSyncTest {
|
||||
private val channelA = "a".repeat(64)
|
||||
private val channelB = "b".repeat(64)
|
||||
|
||||
@Test
|
||||
fun blobWrittenWithoutTheFieldDecodesToNull() {
|
||||
val json = """{"pinnedRooms":[]}"""
|
||||
val decoded = JsonMapper.fromJson<AccountChatPreferencesInternal>(json)
|
||||
assertNull(decoded.mutedPublicChats)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitlyEmptyListDecodesToEmptyNotNull() {
|
||||
val json = """{"pinnedRooms":[],"mutedPublicChats":[]}"""
|
||||
val decoded = JsonMapper.fromJson<AccountChatPreferencesInternal>(json)
|
||||
assertEquals(emptyList<String>(), decoded.mutedPublicChats)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jsonRoundTripPreservesMutedChats() {
|
||||
val internal = AccountChatPreferencesInternal(emptyList(), listOf(channelA, channelB))
|
||||
val decoded = JsonMapper.fromJson<AccountChatPreferencesInternal>(JsonMapper.toJson(internal))
|
||||
assertEquals(listOf(channelA, channelB), decoded.mutedPublicChats)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wireShapeIsSortedSoTheBlobIsStable() {
|
||||
// Mirrors AccountSyncedSettings.toInternal(): mutedPublicChats.sorted().
|
||||
// Two sets with the same members must serialize identically regardless of
|
||||
// iteration order, or every settings save republishes a no-op event.
|
||||
val oneOrder = setOf(channelB, channelA).sorted()
|
||||
val otherOrder = setOf(channelA, channelB).sorted()
|
||||
assertEquals(
|
||||
JsonMapper.toJson(AccountChatPreferencesInternal(emptyList(), oneOrder)),
|
||||
JsonMapper.toJson(AccountChatPreferencesInternal(emptyList(), otherOrder)),
|
||||
)
|
||||
}
|
||||
|
||||
// ---
|
||||
// The merge decision itself, not just the decode.
|
||||
//
|
||||
// The decode tests above prove `null` and `[]` arrive distinguishable. These prove the
|
||||
// merge ACTS on that distinction. Without them, collapsing the guard to
|
||||
// `remote ?: emptyList()` — exactly the mistake the nullable default exists to prevent —
|
||||
// leaves every other test in this file green.
|
||||
// ---
|
||||
|
||||
@Test
|
||||
fun absentKeyLeavesTheLocalMuteSetAlone() {
|
||||
// An older client rewrote the blob and dropped the field. The local set must survive:
|
||||
// AppSpecificState replays the cached backup on every launch, so a wipe here would
|
||||
// repeat on every start.
|
||||
assertEquals(setOf(channelA), mergeMutedPublicChats(setOf(channelA), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absentKeyOnAnEmptyLocalSetStaysEmpty() {
|
||||
assertEquals(emptySet<String>(), mergeMutedPublicChats(emptySet(), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitEmptyListClearsTheLocalMuteSet() {
|
||||
// A client that knows the field saying "unmute everything" must be obeyed.
|
||||
assertEquals(emptySet<String>(), mergeMutedPublicChats(setOf(channelA), emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun remoteListReplacesTheLocalMuteSet() {
|
||||
assertEquals(setOf(channelB), mergeMutedPublicChats(setOf(channelA), listOf(channelB)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The predicate behind every mute suppression point: the row dot, the bottom-bar
|
||||
* badge, the push dispatcher and the Notifications feed all ask this one question,
|
||||
* so they cannot drift apart.
|
||||
*/
|
||||
class MutedPublicChatsTest {
|
||||
private val channelId: HexKey = "4".repeat(64)
|
||||
private val otherChannel: HexKey = "5".repeat(64)
|
||||
private val parentId: HexKey = "6".repeat(64)
|
||||
private val author: HexKey = "b".repeat(64)
|
||||
private val relay = "wss://relay.damus.io"
|
||||
private val sig = "0".repeat(128)
|
||||
|
||||
private fun channelMessage(tags: Array<Array<String>>) = ChannelMessageEvent("3".repeat(64), author, 1778593701L, tags, "hi", sig)
|
||||
|
||||
private fun topLevel() = channelMessage(arrayOf(arrayOf("e", channelId, relay, "root")))
|
||||
|
||||
private fun reply() =
|
||||
channelMessage(
|
||||
arrayOf(
|
||||
arrayOf("e", channelId, relay, "root"),
|
||||
arrayOf("e", parentId, relay, "reply"),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun topLevelMessageResolvesToItsChannel() {
|
||||
assertEquals(channelId, publicChatChannelIdOf(topLevel()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replyResolvesToTheChannelNotItsParent() {
|
||||
// Every message in a NIP-28 channel shares one root, so mute is per-channel.
|
||||
assertEquals(channelId, publicChatChannelIdOf(reply()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonPublicChatEventHasNoChannel() {
|
||||
val dm = ChatMessageEvent("3".repeat(64), author, 1L, arrayOf(arrayOf("p", author)), "hi", sig)
|
||||
assertNull(publicChatChannelIdOf(dm))
|
||||
assertNull(publicChatChannelIdOf(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageInMutedChannelIsMuted() {
|
||||
assertTrue(isMutedPublicChatMessage(topLevel(), setOf(channelId)))
|
||||
assertTrue(isMutedPublicChatMessage(reply(), setOf(channelId)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageInAnotherChannelIsNotMuted() {
|
||||
assertFalse(isMutedPublicChatMessage(topLevel(), setOf(otherChannel)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyMuteSetMutesNothing() {
|
||||
assertFalse(isMutedPublicChatMessage(topLevel(), emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonPublicChatEventIsNeverMuted() {
|
||||
val dm = ChatMessageEvent("3".repeat(64), author, 1L, arrayOf(arrayOf("p", author)), "hi", sig)
|
||||
assertFalse(isMutedPublicChatMessage(dm, setOf(channelId)))
|
||||
assertFalse(isMutedPublicChatMessage(null, setOf(channelId)))
|
||||
}
|
||||
|
||||
// --- Regression coverage: a channel row's newest event is not always a ChannelMessageEvent.
|
||||
// ChannelMetadataEvent (kind 41, e.g. a topic/picture edit) and ChannelCreateEvent (kind 40,
|
||||
// the channel's own creation event) are the other two event types a public-chat row can
|
||||
// dispatch to ChannelRoomCompose — see ChatroomHeaderCompose's `when`. Both
|
||||
// ChatroomRowUnread.rowLastReadRoute and ChatroomListKnownFeedFilter resolve the id
|
||||
// through publicChatChannelIdOf, so this test covers all three sites at once.
|
||||
|
||||
private fun channelMetadata(tags: Array<Array<String>>) = ChannelMetadataEvent("7".repeat(64), author, 1778593701L, tags, "{}", sig)
|
||||
|
||||
@Test
|
||||
fun metadataEventResolvesToItsChannel() {
|
||||
val metadata = channelMetadata(arrayOf(arrayOf("e", channelId, relay, "root")))
|
||||
assertEquals(channelId, publicChatChannelIdOf(metadata))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createEventResolvesToItsOwnId() {
|
||||
val createId = "8".repeat(64)
|
||||
val create = ChannelCreateEvent(createId, author, 1778593701L, arrayOf(), "{}", sig)
|
||||
assertEquals(createId, publicChatChannelIdOf(create))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metadataEventInMutedChannelIsMuted() {
|
||||
val metadata = channelMetadata(arrayOf(arrayOf("e", channelId, relay, "root")))
|
||||
assertTrue(isMutedPublicChatMessage(metadata, setOf(channelId)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelAdminEventIsNotRecognisedAsChannelActivity() {
|
||||
// ChannelMuteUserEvent (and ChannelHideMessageEvent) are channel-admin actions, not
|
||||
// activity that should resolve to a channel id — they must keep falling through.
|
||||
val muteUser = ChannelMuteUserEvent("9".repeat(64), author, 1778593701L, arrayOf(arrayOf("e", channelId, relay, "root")), "", sig)
|
||||
assertNull(publicChatChannelIdOf(muteUser))
|
||||
assertFalse(isMutedPublicChatMessage(muteUser, setOf(channelId)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelMessageWithNoTagsHasNoChannel() {
|
||||
// Right type, but channelId() is null because there is no e-tag at all.
|
||||
val untagged = channelMessage(arrayOf())
|
||||
assertNull(publicChatChannelIdOf(untagged))
|
||||
assertFalse(isMutedPublicChatMessage(untagged, setOf(channelId)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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.buzz
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The channel-type map the invite projection classifies against.
|
||||
*
|
||||
* It exists because reading the type off the [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel]
|
||||
* is only correct *after* `LocalCache.consume(GroupMetadataEvent)` has copied the event into it — and
|
||||
* consume wakes the cache observers one step earlier, so the recompute that the arriving directory
|
||||
* triggers reads an empty channel, answers UNKNOWN, and never runs again. Deriving the type from the
|
||||
* metadata event that caused the emission is what makes that answer stable.
|
||||
*/
|
||||
class BuzzChannelTypesTest {
|
||||
private val relayKey = "b".repeat(64)
|
||||
|
||||
private fun metadata(
|
||||
groupId: String,
|
||||
channelType: String?,
|
||||
): GroupMetadataEvent {
|
||||
val tags = mutableListOf(arrayOf("d", groupId), arrayOf("name", groupId.uppercase()))
|
||||
if (channelType != null) tags.add(arrayOf("t", channelType))
|
||||
return GroupMetadataEvent(
|
||||
id = groupId.padEnd(64, '0'),
|
||||
pubKey = relayKey,
|
||||
createdAt = 1_700_000_000L,
|
||||
tags = tags.toTypedArray(),
|
||||
content = "",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
}
|
||||
|
||||
private fun noteOf(event: Event): Note = AddressableNote((event as GroupMetadataEvent).address()).apply { this.event = event }
|
||||
|
||||
@Test
|
||||
fun `a stream channel is a named channel and a dm channel is a dm`() {
|
||||
val types =
|
||||
buzzChannelTypes(
|
||||
listOf(
|
||||
noteOf(metadata("chan-eng", "stream")),
|
||||
noteOf(metadata("chan-dm", "dm")),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(ChannelClassification.NAMED, types["chan-eng"])
|
||||
assertEquals(ChannelClassification.DM, types["chan-dm"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a channel with no buzz type at all is still a named channel`() {
|
||||
// A vanilla NIP-29 relay has no `channel_type`, and a group there is a group — never a DM.
|
||||
val types = buzzChannelTypes(listOf(noteOf(metadata("chan-plain", null))))
|
||||
|
||||
assertEquals(ChannelClassification.NAMED, types["chan-plain"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a note whose metadata has not arrived contributes nothing`() {
|
||||
// The placeholder case the projection has to withhold on, rather than guess NAMED and flash a
|
||||
// "somebody added you" card in front of every Buzz DM while its directory is in flight.
|
||||
val placeholder = AddressableNote(metadata("chan-unloaded", "stream").address())
|
||||
|
||||
val types = buzzChannelTypes(listOf(placeholder))
|
||||
|
||||
assertNull(types["chan-unloaded"])
|
||||
assertEquals(0, types.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the newest note for a group wins`() {
|
||||
// Two versions of the same addressable can be in flight; the last one applied is the one the
|
||||
// cache kept, and the map must not go back to an older answer.
|
||||
val types =
|
||||
buzzChannelTypes(
|
||||
listOf(
|
||||
noteOf(metadata("chan-switch", "dm")),
|
||||
noteOf(metadata("chan-switch", "stream")),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(ChannelClassification.NAMED, types["chan-switch"])
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.preferences
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The migration precedence in [BuzzAttestationPreferences.restoreFrom]: which of the two on-disk
|
||||
* shapes wins when the held attestation moved from one device-global list to a per-account key.
|
||||
*
|
||||
* The store itself needs a `Context`, so the decision is pulled out as a pure function — this is
|
||||
* the part with the sharp edge, and it is the part the DataStore round-trip cannot express.
|
||||
*/
|
||||
class BuzzAttestationRestoreTest {
|
||||
private val me = "a".repeat(64)
|
||||
private val someoneElse = "b".repeat(64)
|
||||
private val owner = "c".repeat(64)
|
||||
private val sig = "d".repeat(128)
|
||||
|
||||
private fun saved(
|
||||
owner: String = this.owner,
|
||||
conditions: String = "kind=40002",
|
||||
) = """{"owner":"$owner","conditions":"$conditions","sig":"$sig"}"""
|
||||
|
||||
private fun legacyList(vararg agents: String) = agents.joinToString(",", "[", "]") { """{"agent":"$it","owner":"$owner","conditions":"kind=40002","sig":"$sig"}""" }
|
||||
|
||||
@Test
|
||||
fun nothingSavedAnywhereRestoresNothing() {
|
||||
assertNull(BuzzAttestationPreferences.restoreFrom(null, null, me))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thisAccountsOwnKeyWins() {
|
||||
val restored = BuzzAttestationPreferences.restoreFrom(saved(), legacyList(me), me)
|
||||
assertEquals(owner, restored?.ownerPubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRemovedAttestationIsNotResurrectedFromTheLegacyList() {
|
||||
// The regression this test exists for. Removing the held credential used to delete the
|
||||
// per-account key, which is indistinguishable from "never migrated" — so the next launch
|
||||
// seeded it straight back out of the legacy list, which nothing ever clears. An explicit
|
||||
// tombstone is the only thing that can say "migrated, and holding nothing".
|
||||
assertNull(BuzzAttestationPreferences.restoreFrom("", legacyList(me), me))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNeverMigratedAccountTakesItsOwnEntryFromTheLegacyList() {
|
||||
val restored = BuzzAttestationPreferences.restoreFrom(null, legacyList(someoneElse, me), me)
|
||||
assertEquals(owner, restored?.ownerPubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anotherAgentsLegacyEntryIsNeverPickedUp() {
|
||||
// The legacy list was already agent-keyed, so the migration is exact rather than
|
||||
// best-effort: there is no shared blob to accidentally inherit.
|
||||
assertNull(BuzzAttestationPreferences.restoreFrom(null, legacyList(someoneElse), me))
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.relayClient.authCommand.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* "…I'm reading someone I follow" has to actually cover the relays it is about.
|
||||
*
|
||||
* The whole point of the toggle is the outbox relay of somebody else — a relay we do not publish to,
|
||||
* do not read our own inbox from, and do not list. That is exactly the shape `isFirstParty` reports
|
||||
* false for, so requiring it emptied the category: with the toggle explicitly on, every one of the
|
||||
* user's follows still produced a login prompt for its outbox relay.
|
||||
*/
|
||||
class RelayAuthReadFollowsTest {
|
||||
private val followsRelay = "wss://outbox.someone-i-follow.example/"
|
||||
private val followed = "a".repeat(64)
|
||||
private val stranger = "b".repeat(64)
|
||||
|
||||
private class NoStore : RelayAuthPermissionStore {
|
||||
override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = null
|
||||
|
||||
override suspend fun storeDecision(
|
||||
relayUrl: String,
|
||||
decision: RelayAuthDecision,
|
||||
) = Unit
|
||||
|
||||
override suspend fun clearDecision(relayUrl: String) = Unit
|
||||
|
||||
override suspend fun allDecisions(): Map<String, RelayAuthDecision> = emptyMap()
|
||||
}
|
||||
|
||||
private fun ledger(toggles: RelayAuthCustomToggles = RelayAuthCustomToggles()) =
|
||||
RelayAuthPermissionLedger(
|
||||
store = NoStore(),
|
||||
globalPolicy = { RelayAuthPolicy.CUSTOM },
|
||||
customToggles = { toggles },
|
||||
isFollowed = { it == followed },
|
||||
)
|
||||
|
||||
private fun readOutbox(vararg authors: String) = RelayAuthContext(followsRelay, listOf(AuthPurpose(AuthPurposeKind.READ_OUTBOX, authors.toSet())))
|
||||
|
||||
@Test
|
||||
fun readingAFollowAutoAuthenticatesOnTheirOwnOutboxRelay() =
|
||||
runTest {
|
||||
// isFirstParty = false is not an edge case here, it is *the* case: the relay belongs to the
|
||||
// author we are reading. Before the fix this returned ASK, so a user on "decide per relay"
|
||||
// with this toggle on was prompted once per follow.
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ALLOW,
|
||||
ledger().decide(readOutbox(followed), isFirstParty = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readingAFollowStillAsksWhenTheToggleIsOff() =
|
||||
runTest {
|
||||
val off = RelayAuthCustomToggles(readFollows = false)
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ASK,
|
||||
ledger(off).decide(readOutbox(followed), isFirstParty = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readingAStrangerStillAsks() =
|
||||
runTest {
|
||||
// There is deliberately no "read strangers" category — browsing a profile we don't follow
|
||||
// on a relay of theirs is still a question.
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ASK,
|
||||
ledger().decide(readOutbox(stranger), isFirstParty = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneFollowInABatchedReadIsEnough() =
|
||||
runTest {
|
||||
// Outbox reads are batched per relay, so a single filter routinely names a mix. One
|
||||
// followed author in it is the reason we are on this relay at all.
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ALLOW,
|
||||
ledger().decide(readOutbox(stranger, followed), isFirstParty = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messagingIsNotCoveredByTheReadExemption() =
|
||||
runTest {
|
||||
// Delivering to a followed user's *inbox* keeps the first-party gate: the pending event
|
||||
// would be ours, and when it isn't, the traffic belongs to another logged-in account.
|
||||
val ctx =
|
||||
RelayAuthContext(
|
||||
followsRelay,
|
||||
listOf(AuthPurpose(AuthPurposeKind.SEND_DM, setOf(followed))),
|
||||
)
|
||||
assertEquals(RelayAuthVerdict.ASK, ledger().decide(ctx, isFirstParty = false))
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(ctx, isFirstParty = true))
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.buzz
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The always-on workspace-inbox filter: what a Buzz relay addresses to me personally. Pins the `#p`
|
||||
* scope, the kind set, and — most importantly — that the filter carries NO `#h`, because this is the
|
||||
* query that discovers which channels exist for me in the first place.
|
||||
*/
|
||||
class FilterWorkspaceInboxToPubkeyTest {
|
||||
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
|
||||
private val me = "a".repeat(64)
|
||||
|
||||
@Test
|
||||
fun `builds a single p-scoped filter over the workspace inbox kinds`() {
|
||||
val filters = filterWorkspaceInboxToPubkey(relay, me, since = 500L)
|
||||
|
||||
val f = filters.single()
|
||||
assertEquals(relay, f.relay)
|
||||
assertEquals(listOf(me), f.filter.tags!!["p"])
|
||||
assertEquals(500L, f.filter.since)
|
||||
assertNull(f.filter.until)
|
||||
assertNull(f.filter.authors)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `carries no channel scope`() {
|
||||
// A `#h` here would be a contradiction: the channel ids are what this query is FOR. It also has
|
||||
// to stay off any subscription that does carry one — buzz downgrades a mixed subscription to
|
||||
// "global", which never receives channel-scoped events.
|
||||
val f = filterWorkspaceInboxToPubkey(relay, me, since = null).single()
|
||||
|
||||
assertNull(f.filter.tags!!["h"])
|
||||
assertEquals(setOf("p"), f.filter.tags!!.keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asks for both membership verdicts and the hidden-DM snapshot`() {
|
||||
val kinds = filterWorkspaceInboxToPubkey(relay, me, since = null).single().filter.kinds!!
|
||||
|
||||
assertTrue(kinds.contains(44100)) // MemberAddedNotificationEvent
|
||||
assertTrue(kinds.contains(44101)) // MemberRemovedNotificationEvent — withdraws an add
|
||||
assertTrue(kinds.contains(30622)) // DmVisibilityEvent — which DMs I hid
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the removal kind travels with the add kind`() {
|
||||
// The invite projection resolves each channel to its NEWEST verdict, so asking for adds without
|
||||
// removals would leave a prompt standing for a membership the relay already took away.
|
||||
assertTrue(MembershipNotificationKinds.contains(44100))
|
||||
assertTrue(MembershipNotificationKinds.contains(44101))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no pubkey produces no filter`() {
|
||||
assertTrue(filterWorkspaceInboxToPubkey(relay, null, since = null).isEmpty())
|
||||
assertTrue(filterWorkspaceInboxToPubkey(relay, "", since = null).isEmpty())
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.relayClient.reqCommand.account.nip60Cashu
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the backward-paging Cashu history filter: it must ask for the N newest kind:7376 rows I authored
|
||||
* strictly OLDER than a cursor (`until`+`limit`, no `since`), so the single per-relay cursor the
|
||||
* [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager] tracks
|
||||
* can't skip a band and an empty page truly means "nothing older" (see RelayLoadingCursors).
|
||||
*/
|
||||
class FilterCashuHistoryTest {
|
||||
private val relay = RelayUrlNormalizer.normalize("wss://outbox.example.com")
|
||||
private val pubkey = "aa".repeat(32)
|
||||
private val until = 1_700_000_000L
|
||||
|
||||
@Test
|
||||
fun `history filter asks one until+limit page of my own rows, no since`() {
|
||||
val filters = filterCashuHistoryToPubkey(relay, pubkey, until, 100)
|
||||
|
||||
assertEquals(1, filters.size)
|
||||
val f = filters.first().filter
|
||||
assertEquals(relay, filters.first().relay)
|
||||
assertEquals(until, f.until)
|
||||
assertEquals(100, f.limit)
|
||||
assertNull("history pages by until, never since", f.since)
|
||||
// Own events are read back by author, not by a #p tag — unlike notifications, these are mine.
|
||||
assertEquals(listOf(pubkey), f.authors)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history filter is scoped to kind 7376 alone`() {
|
||||
val f = filterCashuHistoryToPubkey(relay, pubkey, until, 100).first().filter
|
||||
|
||||
assertEquals(listOf(CashuSpendingHistoryEvent.KIND), f.kinds)
|
||||
// Proofs must never be paged on demand: a balance summed over a partial kind:7375 set is wrong,
|
||||
// not merely incomplete, so those are walked to exhaustion by CashuWalletState instead.
|
||||
assertTrue(
|
||||
"kind:7375 must not ride along on a demand-paged query",
|
||||
CashuTokenEvent.KIND !in f.kinds.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty pubkey yields no filter`() {
|
||||
assertTrue(filterCashuHistoryToPubkey(relay, null, until, 100).isEmpty())
|
||||
assertTrue(filterCashuHistoryToPubkey(relay, "", until, 100).isEmpty())
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInviteCard
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Notifications tab's card order. An unanswered invite is a standing question, so it outranks every
|
||||
* dated row no matter how old it is; everything else keeps the ordinary newest-first order.
|
||||
*
|
||||
* A plain `created_at` sort is what would let a week of reactions bury a decision the user still has to
|
||||
* make — and, once the feed passes `limit()`, page it off the end entirely.
|
||||
*/
|
||||
class NotificationFeedOrderCardTest {
|
||||
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
|
||||
|
||||
/** A stand-in dated card. Only [Card.createdAt] and [Card.id] matter to the comparator. */
|
||||
private class DatedCard(
|
||||
private val createdAt: Long,
|
||||
private val id: String,
|
||||
) : Card {
|
||||
override fun createdAt() = createdAt
|
||||
|
||||
override fun id() = id
|
||||
}
|
||||
|
||||
private fun invite(
|
||||
id: String,
|
||||
createdAt: Long,
|
||||
) = ChannelInviteCard(Note(id), BuzzChannelInvite(id, "chan-$id", relay, null, createdAt))
|
||||
|
||||
@Test
|
||||
fun `a stale invite still outranks every dated row`() {
|
||||
val cards =
|
||||
listOf(
|
||||
DatedCard(9_000L, "fresh-reaction"),
|
||||
invite("old-invite", 1_000L),
|
||||
DatedCard(8_000L, "older-reaction"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("old-invite", "fresh-reaction", "older-reaction"),
|
||||
cards.sortedWith(NotificationFeedOrderCard).map { it.id() },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invites sort newest-first among themselves`() {
|
||||
val cards =
|
||||
listOf(
|
||||
invite("older", 1_000L),
|
||||
invite("newest", 3_000L),
|
||||
invite("middle", 2_000L),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("newest", "middle", "older"),
|
||||
cards.sortedWith(NotificationFeedOrderCard).map { it.id() },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dated rows keep the default order behind the invites`() {
|
||||
val cards = listOf(DatedCard(1_000L, "b"), DatedCard(3_000L, "a"), DatedCard(2_000L, "c"))
|
||||
|
||||
assertEquals(
|
||||
cards.sortedWith(DefaultFeedOrderCard).map { it.id() },
|
||||
cards.sortedWith(NotificationFeedOrderCard).map { it.id() },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the comparator is a total order so sorting never throws`() {
|
||||
// Cards tie on created_at routinely (a batch of reactions lands in the same second), and an
|
||||
// inconsistent comparator makes TimSort throw "Comparison method violates its general contract".
|
||||
val cards =
|
||||
listOf(
|
||||
DatedCard(1_000L, "b"),
|
||||
DatedCard(1_000L, "a"),
|
||||
invite("x", 1_000L),
|
||||
invite("y", 1_000L),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("x", "y", "a", "b"),
|
||||
cards.sortedWith(NotificationFeedOrderCard).map { it.id() },
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -423,10 +423,11 @@ amy's on-relay events match the app's. NUT-13 counters persist in
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]` | Publish a kind:17375 wallet + kind:10019 nutzap info. Advertises your outbox relays for nutzaps unless `--relay` overrides. |
|
||||
| `amy cashu wallet show` | P2PK pubkey, mints, balance, per-mint balances, proof/history/pending counts. |
|
||||
| `amy cashu wallet show [--sync]` | P2PK pubkey, mints, balance, per-mint balances, proof/history/pending counts. `--sync` pulls from the relays first. |
|
||||
| `amy cashu wallet export-key` | Decrypt and print the wallet's P2PK private key. |
|
||||
| `amy cashu wallet destroy` | Withdraw the nutzap advertisement and NIP-09 delete the wallet (leaves token events — the ecash still lives at the mint). |
|
||||
| `amy cashu balance [--mint URL]` | Spendable balance from the local store (optionally one mint). |
|
||||
| `amy cashu sync` | Page every NIP-60/61 event off the relays into the local store, then report the balance and the proof/history counts. Every other `cashu` read projects the store and never touches the network, so this is what fills it — run it first on a machine that didn't create the wallet. |
|
||||
| `amy cashu balance [--mint URL] [--sync]` | Spendable balance from the local store (optionally one mint). `--sync` pages from the relays first. |
|
||||
| `amy cashu mint ping URL` / `info URL` | Stateless `/v1/info` probe (name/pubkey/version) / full DTO. |
|
||||
| `amy cashu receive ln SATS [--mint URL]` | Request a mint quote; prints the bolt11 + kind:7374 quote. |
|
||||
| `amy cashu receive complete QUOTE_ID` | Poll the quote; once the invoice is settled, mint proofs (kind:7375 + kind:7376). (`resume` is a deprecated alias.) |
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| Long-form (NIP-23) publish / read | 🆕 | |
|
||||
| Live activities / chess (NIP-53 / NIP-64) | 🆕 | |
|
||||
| Blossom blobs (NIP-B7) | ✅ | `BlossomCommands` — upload/download/list/delete/check/mirror on shared `commons` `BlossomClient`; live-server harness at `cli/tests/blossom/`. |
|
||||
| NIP-60 / 61 Cashu wallet + nutzaps | ✅ | Full surface: `cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `balance`, `receive {ln,complete,resume,token,nutzap-sweep}`, `send {ln,token,nutzap}`, `maintenance {scrub,restore,migrate-keysets}`, `mint-rec {show,add,remove}` — all on shared `commons` `CashuWalletOps` + `CashuWalletReader` (the exact path the Android wallet runs). Interop harness pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). |
|
||||
| NIP-60 / 61 Cashu wallet + nutzaps | ✅ | Full surface: `cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `sync`, `balance`, `receive {ln,complete,resume,token,nutzap-sweep}`, `send {ln,token,nutzap}`, `maintenance {scrub,restore,migrate-keysets}`, `mint-rec {show,add,remove}` — all on shared `commons` `CashuWalletOps` + `CashuWalletReader` (the exact path the Android wallet runs). Reads project the local store; `cashu sync` (or `--sync`) is what fills it, paging every relay to exhaustion so a cap can't truncate the proof set. Interop harness pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). |
|
||||
| NIP-47 Wallet Connect | 🆕 | |
|
||||
| NIP-46 bunker signer | ✅ | `BunkerCommand` + `NostrConnect` + `LoginCommand` — host (`amy bunker[ connect]`) and client (`amy login bunker://` / `--nostrconnect`) sides, `--perms`/`--interactive` gating, `auth_url` challenges. |
|
||||
| Profile view (`amy profile show NPUB`) + edit | ✅ | `ProfileCommands`. Cache-first; `--refresh` forces a relay drain. |
|
||||
|
||||
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.cli.stores.FileCashuKeysetCounterStore
|
||||
import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuInboundNutzapBackfillFilters
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuOwnEventBackfillFilters
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
@@ -92,11 +94,60 @@ class CashuContext(
|
||||
reserveCashuCounters = { keysetId, count -> counters.reserve(keysetId, count) },
|
||||
)
|
||||
|
||||
/**
|
||||
* Page this account's whole NIP-60/61/87 event set off the relays into the
|
||||
* local store, so the next [snapshot] projects a complete wallet rather than
|
||||
* whatever happened to be synced. Returns the number of events delivered
|
||||
* (duplicates across relays already deduped by [Context.drainAllPages]).
|
||||
*
|
||||
* ### Why paging, and why this is opt-in
|
||||
*
|
||||
* [snapshot] reads the store and nothing else — that is amy's contract, and
|
||||
* it is why `cashu balance` is instant and offline-capable. The cost is that
|
||||
* the balance is only ever as complete as whatever last filled the store,
|
||||
* and nothing in amy fetched the NIP-60 kinds at all: a wallet created on
|
||||
* the phone read zero here. So this is a verb (`amy cashu sync`) and a flag
|
||||
* (`--sync`), never an implicit round-trip inside a read.
|
||||
*
|
||||
* It pages rather than issuing one REQ because a relay answers an unbounded
|
||||
* REQ with its own cap applied to the newest matching events, and kind:7376
|
||||
* history outnumbers the kind:7375 proofs by an order of magnitude on a
|
||||
* wallet with any history — so the events that fall off the bottom are the
|
||||
* proofs at mints the user hasn't touched lately, and the balance reads low
|
||||
* with nothing to indicate it. `drainAllPages` walks each relay on its own
|
||||
* `until` cursor to exhaustion, which is the only way to be sure.
|
||||
*
|
||||
* Split across relay sets exactly like the Android subscription: own events
|
||||
* from the outbox (where they were published), inbound nutzaps from the
|
||||
* inbox (where senders deliver them).
|
||||
*/
|
||||
suspend fun sync(): Int {
|
||||
val pk = ctx.identity.pubKeyHex
|
||||
val outbox = ctx.outboxRelays()
|
||||
val inbox = ctx.inboxRelays()
|
||||
|
||||
val own =
|
||||
if (outbox.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
ctx.drainAllPages(outbox.associateWith { cashuOwnEventBackfillFilters(pk) })
|
||||
}
|
||||
val nutzaps =
|
||||
if (inbox.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
ctx.drainAllPages(inbox.associateWith { cashuInboundNutzapBackfillFilters(pk) })
|
||||
}
|
||||
|
||||
return own.size + nutzaps.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Project this account's locally-stored NIP-60/61/87 events into a wallet
|
||||
* snapshot via the shared [CashuWalletReader] — the same decrypt +
|
||||
* del-rollover + pending-quote logic the Android holder runs. Reads the
|
||||
* cache only; commands that need fresh state should [Context.drain] first.
|
||||
* cache only; commands that need fresh state should [sync] (or
|
||||
* [Context.drain]) first.
|
||||
*/
|
||||
suspend fun snapshot(): CashuWalletReader.WalletSnapshot {
|
||||
val pk = ctx.identity.pubKeyHex
|
||||
|
||||
+9
-2
@@ -26,8 +26,13 @@ import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
|
||||
/**
|
||||
* `amy cashu balance [--mint URL]` — spendable balance from the local store,
|
||||
* via the shared CashuWalletReader projection. Optionally filtered to one mint.
|
||||
* `amy cashu balance [--mint URL] [--sync]` — spendable balance from the local
|
||||
* store, via the shared CashuWalletReader projection. Optionally filtered to one
|
||||
* mint.
|
||||
*
|
||||
* `--sync` pages the wallet off the relays first (see [CashuContext.sync]).
|
||||
* Without it this is a pure local read, and reports only what the store already
|
||||
* holds — which for a wallet created elsewhere may be nothing at all.
|
||||
*/
|
||||
object CashuBalanceCommand {
|
||||
suspend fun run(
|
||||
@@ -36,8 +41,10 @@ object CashuBalanceCommand {
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val mintFilter = args.flag("mint")?.trimEnd('/')
|
||||
val sync = args.bool("sync")
|
||||
args.rejectUnknown()
|
||||
Context.open(dataDir).use { ctx ->
|
||||
if (sync) ctx.cashu.sync()
|
||||
val snap = ctx.cashuSnapshot()
|
||||
val byMint =
|
||||
snap.balancesByMint.let { all ->
|
||||
|
||||
@@ -37,11 +37,13 @@ object CashuCommands {
|
||||
|Cashu wallet (NIP-60 / NIP-61):
|
||||
| cashu wallet create [--mint URL] [--mints a,b] publish a kind:17375 wallet + kind:10019
|
||||
| [--privkey HEX] [--relay r1,r2] nutzap info
|
||||
| cashu wallet show P2PK pubkey, mints, balances, counts
|
||||
| cashu wallet show [--sync] P2PK pubkey, mints, balances, counts
|
||||
| cashu wallet export-key decrypt + print the wallet's P2PK key
|
||||
| cashu wallet destroy withdraw nutzap ad + NIP-09 delete wallet
|
||||
| cashu mint ping URL / info URL stateless /v1/info probe (no account)
|
||||
| cashu balance [--mint URL] spendable balance from the local store
|
||||
| cashu sync page every NIP-60/61 event off the relays
|
||||
| into the local store, then report balance
|
||||
| cashu balance [--mint URL] [--sync] spendable balance from the local store
|
||||
| cashu receive ln SATS [--mint URL] request a mint quote (bolt11 + kind:7374)
|
||||
| cashu receive complete QUOTE_ID poll the quote; mint proofs once settled
|
||||
| cashu receive token TOKEN redeem a cashuB… token into the wallet
|
||||
@@ -65,12 +67,13 @@ object CashuCommands {
|
||||
route(
|
||||
name = "cashu",
|
||||
tail = tail,
|
||||
usage = "cashu <wallet|mint|balance|receive|send|maintenance|mint-rec>",
|
||||
usage = "cashu <wallet|mint|sync|balance|receive|send|maintenance|mint-rec>",
|
||||
help = USAGE,
|
||||
routes =
|
||||
mapOf(
|
||||
"wallet" to { rest -> CashuWalletCommands.dispatch(dataDir, rest) },
|
||||
"mint" to { rest -> CashuMintCommands.dispatch(rest) },
|
||||
"sync" to { rest -> CashuSyncCommand.run(dataDir, rest) },
|
||||
"balance" to { rest -> CashuBalanceCommand.run(dataDir, rest) },
|
||||
"receive" to { rest -> CashuReceiveCommands.dispatch(dataDir, rest) },
|
||||
"send" to { rest -> CashuSendCommands.dispatch(dataDir, rest) },
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.cli.commands.cashu
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
|
||||
/**
|
||||
* `amy cashu sync` — page the whole NIP-60/61 event set off the relays into the
|
||||
* local store, then report the resulting balance.
|
||||
*
|
||||
* Every other `cashu` read command projects the local store and never touches
|
||||
* the network, which is what makes them instant and offline-capable — but it
|
||||
* also means they only ever saw whatever else had filled the store, and nothing
|
||||
* in amy fetched the NIP-60 kinds at all. This is the verb that fills it.
|
||||
*
|
||||
* Reports both the balance and the proof/history counts so a caller can tell a
|
||||
* genuinely empty wallet from an unsynced one.
|
||||
*/
|
||||
object CashuSyncCommand {
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
args.rejectUnknown()
|
||||
Context.open(dataDir).use { ctx ->
|
||||
val downloaded = ctx.cashu.sync()
|
||||
val snap = ctx.cashuSnapshot()
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"events_downloaded" to downloaded,
|
||||
"balance_sats" to snap.balanceSats,
|
||||
"balances_by_mint" to snap.balancesByMint,
|
||||
"proofs_count" to snap.tokenEntries.sumOf { it.content.proofs.size },
|
||||
"token_events" to snap.tokenEntries.size,
|
||||
"history_events" to snap.history.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -115,9 +115,22 @@ object CashuWalletCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val sync = args.bool("sync")
|
||||
args.rejectUnknown()
|
||||
Context.open(dataDir).use { ctx ->
|
||||
if (sync) ctx.cashu.sync()
|
||||
val snap = ctx.cashuSnapshot()
|
||||
if (snap.walletEvent == null) return Output.error("no_wallet", "no kind:17375 wallet in the local store — run `cashu wallet create`")
|
||||
// "Not in the store" is not the same as "does not exist": a wallet created on another
|
||||
// client is on the relays and simply hasn't been pulled down here yet, and telling the
|
||||
// user to `create` one in that state would publish a fresh kind:17375 over a replaceable
|
||||
// slot that already holds theirs. Point at `sync` first.
|
||||
if (snap.walletEvent == null) {
|
||||
return Output.error(
|
||||
"no_wallet",
|
||||
"no kind:17375 wallet in the local store — run `cashu sync` to pull an existing one, or `cashu wallet create`",
|
||||
)
|
||||
}
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"p2pk_pubkey" to snap.nutzapInfoEvent?.p2pkPubkey(),
|
||||
|
||||
+20
-4
@@ -560,11 +560,27 @@ object ConcordActions {
|
||||
fun guestbookMembers(
|
||||
wraps: List<Event>,
|
||||
guestbook: GroupKey,
|
||||
): Set<HexKey> {
|
||||
): Set<HexKey> = projectGuestbook(wraps.mapNotNull { guestbookEntry(it, guestbook) })
|
||||
|
||||
/**
|
||||
* Opens a single guestbook [wrap] into its entry, or null when it doesn't belong to
|
||||
* [guestbook] or isn't a guestbook rumor.
|
||||
*
|
||||
* Split out of [guestbookMembers] so a caller holding a growing wrap buffer can memoize the
|
||||
* open per wrap id: opening is the expensive half (two NIP-44 decrypts plus the wrap and seal
|
||||
* signature verifies), while [projectGuestbook] over the already-opened entries is trivial.
|
||||
* Re-projecting a buffer of n wraps on every arrival without that memo is quadratic in
|
||||
* decryptions — see [ConcordCommunitySession]'s guestbook cache.
|
||||
*/
|
||||
fun guestbookEntry(
|
||||
wrap: Event,
|
||||
guestbook: GroupKey,
|
||||
): GuestbookEntry? = ConcordStreamEnvelope.openOrNull(wrap, guestbook)?.rumor?.let { Guestbook.parse(it) }
|
||||
|
||||
/** Last-writer-wins projection of already-opened [entries] down to the JOINed member set. */
|
||||
fun projectGuestbook(entries: Collection<GuestbookEntry>): Set<HexKey> {
|
||||
val latest = HashMap<HexKey, GuestbookEntry>()
|
||||
for (wrap in wraps) {
|
||||
val rumor = ConcordStreamEnvelope.openOrNull(wrap, guestbook)?.rumor ?: continue
|
||||
val entry = Guestbook.parse(rumor) ?: continue
|
||||
for (entry in entries) {
|
||||
val prev = latest[entry.member.lowercase()]
|
||||
if (prev == null || entry.createdAt > prev.createdAt) latest[entry.member.lowercase()] = entry
|
||||
}
|
||||
|
||||
+116
-62
@@ -21,16 +21,18 @@
|
||||
package com.vitorpamplona.amethyst.commons.model.buzz
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** Somebody added [viewer] to a channel: who did it, where, and when. */
|
||||
/**
|
||||
* Somebody added me to a channel: who did it, where, and when.
|
||||
*
|
||||
* [eventId] is the kind-44100 this was projected from — the identity the Notifications feed keys its
|
||||
* card on, so an invite is one row per relay verdict exactly like every other notification.
|
||||
*/
|
||||
@Immutable
|
||||
class BuzzChannelInvite(
|
||||
val eventId: HexKey,
|
||||
val channelId: String,
|
||||
val relay: NormalizedRelayUrl,
|
||||
val actor: HexKey?,
|
||||
@@ -38,74 +40,126 @@ class BuzzChannelInvite(
|
||||
)
|
||||
|
||||
/**
|
||||
* App-wide, per-viewer set of channels somebody **else** added the viewer to, awaiting the viewer's
|
||||
* decision about whether they appear on Messages.
|
||||
* One relay-signed membership verdict addressed to the viewer — a kind-44100 (`removed = false`) or a
|
||||
* kind-44101 (`removed = true`), flattened out of the event so the projection below can be a pure
|
||||
* function over a list and tested without a cache.
|
||||
*/
|
||||
@Immutable
|
||||
class MembershipNotice(
|
||||
val eventId: HexKey,
|
||||
val channelId: String,
|
||||
val relay: NormalizedRelayUrl,
|
||||
val actor: HexKey?,
|
||||
val createdAt: Long,
|
||||
val removed: Boolean,
|
||||
)
|
||||
|
||||
/** What a channel's kind-39000 says it is, once it has loaded. */
|
||||
enum class ChannelClassification {
|
||||
/** `t = dm` — a real Buzz DM. Never an invite; the DM inbox owns it. */
|
||||
DM,
|
||||
|
||||
/** Any other `t` — a named channel somebody put me in. */
|
||||
NAMED,
|
||||
|
||||
/** The kind-39000 hasn't arrived yet, so we cannot tell the two apart. */
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
/**
|
||||
* Projections over the Buzz relay's membership notifications (`#p` = me), which are the *only*
|
||||
* enumeration of the channels a viewer belongs to on a Buzz relay.
|
||||
*
|
||||
* On a Buzz relay, membership is server-side: another member issues the add, the relay writes you into
|
||||
* the channel's kind-39002 roster, and you can immediately read and post. The relay then addresses you a
|
||||
* kind-44100 with `{"actor": …}` naming who did it — and it emits the *same* kind for a self-join, with
|
||||
* `actor == you`, which is the only thing separating the two cases.
|
||||
* Membership there is server-side: another member issues the add, the relay writes the viewer into the
|
||||
* channel's kind-39002 roster, and the viewer can immediately read and post. The relay then addresses
|
||||
* them a kind-44100 whose body names the actor — and it emits the *same* kind for a self-join, with
|
||||
* `actor == me`, which is the only thing separating the two cases. A kind-44101 withdraws the
|
||||
* membership again.
|
||||
*
|
||||
* Amethyst used to funnel every 44100 into [BuzzDmChannels], which silently subscribed the viewer to the
|
||||
* channel's messages while the Messages list — which reads the self-published kind-10009 — showed no row
|
||||
* for it. So a channel could be simultaneously joined (relay roster, no Join button, composer enabled),
|
||||
* streaming messages, and invisible. Only `t = dm` channels belong in [BuzzDmChannels]; everything else
|
||||
* lands here until the viewer accepts.
|
||||
* ### Why this is a projection and not a registry
|
||||
*
|
||||
* Accepting adds the group to kind-10009 (`Account.follow`), after which the normal joined-group path
|
||||
* owns it and the entry is dropped. Dismissing is a *display* choice recorded in
|
||||
* `AccountSettings.dismissedChannelInvites`; genuinely leaving is a kind-9022 `LeaveRequestEvent`, which
|
||||
* is a different action because the viewer really is a member until the relay says otherwise.
|
||||
* This used to be a process-wide mutable registry that discovery `record`ed into and classification
|
||||
* `remove`d from. Both halves of the state were derived from events the cache already held, so the
|
||||
* registry was a second source of truth that could — and did — drift from it: the classification's
|
||||
* removal was remembered nowhere, so any re-delivery of the same kind-44100 re-added an invite that had
|
||||
* already been withdrawn, and the prompt flickered in and out. Deriving instead means the answer is a
|
||||
* pure function of (notices, dismissals, joined list, channel types) and cannot disagree with the cache
|
||||
* that produced it.
|
||||
*/
|
||||
object BuzzChannelInvites {
|
||||
private val lock = KmpLock()
|
||||
private val byViewer = HashMap<HexKey, MutableMap<String, BuzzChannelInvite>>()
|
||||
private val mutableFlow = MutableStateFlow<Map<HexKey, Map<String, BuzzChannelInvite>>>(emptyMap())
|
||||
|
||||
/** Per-viewer pending invites (`channelId` -> who/where/when). */
|
||||
val flow: StateFlow<Map<HexKey, Map<String, BuzzChannelInvite>>> = mutableFlow
|
||||
/**
|
||||
* The newest verdict per channel. Newest wins because membership is a running state, not a log: an
|
||||
* add followed by a remove is *not* a member, and a re-add after that is. A tie in `created_at`
|
||||
* resolves to the removal — the conservative side, since offering "Accept" on a membership the relay
|
||||
* has taken away is an action that cannot succeed.
|
||||
*/
|
||||
fun latestPerChannel(notices: List<MembershipNotice>): Map<String, MembershipNotice> {
|
||||
val newest = HashMap<String, MembershipNotice>(notices.size)
|
||||
notices.forEach { notice ->
|
||||
val current = newest[notice.channelId]
|
||||
val wins =
|
||||
current == null ||
|
||||
notice.createdAt > current.createdAt ||
|
||||
(notice.createdAt == current.createdAt && notice.removed)
|
||||
if (wins) newest[notice.channelId] = notice
|
||||
}
|
||||
return newest
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that somebody added [viewer] to [channelId]. Returns true when this is newly seen, so
|
||||
* callers can invalidate a feed; a repeat of the same (viewer, channel) returns false rather than
|
||||
* churning the flow — the relay re-sends the notification on every reconnect.
|
||||
* Every channel the viewer is currently in (`channelId` -> the relay that vouched for it),
|
||||
* irrespective of who added them or what type the channel turns out to be.
|
||||
*
|
||||
* This is what the directory fetch iterates: a channel's type is only knowable once its kind-39000
|
||||
* has been fetched *by id*, so the fetch has to cover channels that will later be classified out.
|
||||
*/
|
||||
fun record(
|
||||
viewer: HexKey,
|
||||
invite: BuzzChannelInvite,
|
||||
): Boolean =
|
||||
lock.withLock {
|
||||
val invites = byViewer.getOrPut(viewer) { mutableMapOf() }
|
||||
if (invites.containsKey(invite.channelId)) return@withLock false
|
||||
invites[invite.channelId] = invite
|
||||
mutableFlow.value = snapshot()
|
||||
true
|
||||
}
|
||||
fun currentMemberships(notices: List<MembershipNotice>): Map<String, NormalizedRelayUrl> =
|
||||
latestPerChannel(notices)
|
||||
.values
|
||||
.filterNot { it.removed }
|
||||
.associate { it.channelId to it.relay }
|
||||
|
||||
/**
|
||||
* Drops an invite once it is no longer pending — the viewer accepted it (now in kind-10009), left the
|
||||
* channel, or the relay reported a kind-44101 removal.
|
||||
* The channels somebody **else** put the viewer in that are still awaiting a decision, newest first.
|
||||
*
|
||||
* An entry is withheld when it is not a question:
|
||||
* - the newest verdict is a removal — there is no membership left to accept;
|
||||
* - the actor is the viewer, so this is a self-join, not somebody else's doing;
|
||||
* - the channel is on the viewer's kind-10009 list ([joined]) — accepted already, and the ordinary
|
||||
* Messages row owns it;
|
||||
* - the viewer dismissed it ([dismissed]) — a local, reversible display choice;
|
||||
* - [classify] does not (yet) say it is a named channel.
|
||||
*
|
||||
* That last rule is deliberately positive: an [ChannelClassification.UNKNOWN] channel is withheld
|
||||
* rather than shown. A Buzz DM arrives as the same kind-44100 as a channel add and is only told
|
||||
* apart once its kind-39000 lands, so surfacing on unknown means every new DM flashes up a "somebody
|
||||
* added you to a channel" card for as long as the directory fetch takes, and then withdraws it.
|
||||
* Waiting costs a beat on a genuine invite; not waiting is a wrong prompt on every DM.
|
||||
*/
|
||||
fun remove(
|
||||
fun pendingInvites(
|
||||
viewer: HexKey,
|
||||
channelId: String,
|
||||
): Boolean =
|
||||
lock.withLock {
|
||||
val invites = byViewer[viewer] ?: return@withLock false
|
||||
if (invites.remove(channelId) == null) return@withLock false
|
||||
mutableFlow.value = snapshot()
|
||||
true
|
||||
}
|
||||
notices: List<MembershipNotice>,
|
||||
dismissed: Set<String>,
|
||||
joined: Set<String>,
|
||||
classify: (channelId: String, relay: NormalizedRelayUrl) -> ChannelClassification,
|
||||
): List<BuzzChannelInvite> =
|
||||
latestPerChannel(notices)
|
||||
.values
|
||||
.asSequence()
|
||||
.filterNot { it.removed }
|
||||
.filterNot { it.actor != null && it.actor.equals(viewer, ignoreCase = true) }
|
||||
.filterNot { it.channelId in dismissed || it.channelId in joined }
|
||||
.filter { classify(it.channelId, it.relay) == ChannelClassification.NAMED }
|
||||
.map { BuzzChannelInvite(it.eventId, it.channelId, it.relay, it.actor, it.createdAt) }
|
||||
.sortedByDescending { it.createdAt }
|
||||
.toList()
|
||||
|
||||
/** Invites pending for [viewer], possibly empty. */
|
||||
fun invitesFor(viewer: HexKey): Map<String, BuzzChannelInvite> = mutableFlow.value[viewer] ?: emptyMap()
|
||||
|
||||
private fun snapshot(): Map<HexKey, Map<String, BuzzChannelInvite>> = byViewer.mapValues { it.value.toMap() }
|
||||
|
||||
/** Test-only: clears all state so unit tests don't leak into each other. */
|
||||
fun clearForTesting() =
|
||||
lock.withLock {
|
||||
byViewer.clear()
|
||||
mutableFlow.value = emptyMap()
|
||||
}
|
||||
/** [pendingInvites] keyed by the kind-44100 that produced each one, for per-note lookups. */
|
||||
fun pendingInvitesByEventId(
|
||||
viewer: HexKey,
|
||||
notices: List<MembershipNotice>,
|
||||
dismissed: Set<String>,
|
||||
joined: Set<String>,
|
||||
classify: (channelId: String, relay: NormalizedRelayUrl) -> ChannelClassification,
|
||||
): Map<HexKey, BuzzChannelInvite> = pendingInvites(viewer, notices, dismissed, joined, classify).associateBy { it.eventId }
|
||||
}
|
||||
|
||||
+8
-8
@@ -28,10 +28,15 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
* NIP-29 `h`/UUID, globally unique on Buzz). Starred channels float to the top of the community view.
|
||||
*
|
||||
* There is no Nostr event for a personal star — it's the client's own bookkeeping — so, like
|
||||
* [BuzzWorkspaces], this is a process-wide singleton mirrored to a device-global store by the
|
||||
* platform ([com.vitorpamplona.amethyst] `BuzzChannelStarPreferences`) and restored at startup.
|
||||
* [BuzzWorkspaces], it is mirrored to disk by the platform
|
||||
* ([com.vitorpamplona.amethyst] `BuzzChannelStarPreferences`) and restored at startup.
|
||||
*
|
||||
* **One instance per account** (`Account.buzzChannelStars`). A star is by definition personal: it
|
||||
* says which channels *this user* wants pinned to the top of the community view. While it was a
|
||||
* process-wide singleton, one account's favorites reordered and badged every other logged-in
|
||||
* account's channel list — and switching accounts silently rewrote the set they shared.
|
||||
*/
|
||||
object BuzzChannelStars {
|
||||
class BuzzChannelStars {
|
||||
private val starred = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** The starred channel ids; the community view collects this to pin + badge them. */
|
||||
@@ -52,9 +57,4 @@ object BuzzChannelStars {
|
||||
fun restore(ids: Set<String>) {
|
||||
starred.value = ids
|
||||
}
|
||||
|
||||
/** Test-only: clears the set so unit tests don't leak state into each other. */
|
||||
fun clearForTesting() {
|
||||
starred.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -85,6 +85,35 @@ object BuzzDmChannels {
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets [viewer]'s whole DM set at once, replacing whatever was there.
|
||||
*
|
||||
* This is what discovery uses, because its input is a *recomputation* from the cache rather than a
|
||||
* stream of deltas: every pass sees the complete membership picture, so declaring the result is both
|
||||
* simpler and idempotent. Incremental [record]/[remove] against a recomputed set would ping-pong —
|
||||
* classification removes a named channel, the next pass re-derives it from the same kind-44100 and
|
||||
* re-adds it, and the flow churns forever with nothing having changed.
|
||||
*
|
||||
* Returns true when the set actually moved, so callers can skip work on a no-op pass.
|
||||
*/
|
||||
fun replace(
|
||||
viewer: HexKey,
|
||||
channels: Map<String, NormalizedRelayUrl>,
|
||||
): Boolean =
|
||||
lock.withLock {
|
||||
val current = byViewer[viewer]
|
||||
if (current == null && channels.isEmpty()) return@withLock false
|
||||
if (current != null && current == channels) return@withLock false
|
||||
|
||||
if (channels.isEmpty()) {
|
||||
byViewer.remove(viewer)
|
||||
} else {
|
||||
byViewer[viewer] = channels.toMutableMap()
|
||||
}
|
||||
mutableFlow.value = snapshot()
|
||||
true
|
||||
}
|
||||
|
||||
/** The DM channels [viewer] is in (`channelId` -> relay), possibly empty. */
|
||||
fun channelsFor(viewer: HexKey): Map<String, NormalizedRelayUrl> = mutableFlow.value[viewer] ?: emptyMap()
|
||||
|
||||
|
||||
+34
-58
@@ -26,75 +26,51 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Holds the NIP-OA [OwnerAttestation]s this device has *received* — one owner-signed
|
||||
* authorization per agent key that lets that key publish in the owner's Buzz workspace
|
||||
* without being enrolled as a relay member.
|
||||
* The NIP-OA [OwnerAttestation] this account holds — an owner-signed authorization letting its key
|
||||
* publish in the owner's Buzz workspace without being enrolled as a relay member.
|
||||
*
|
||||
* The counterpart of issuance ([OwnerAttestation] is signed by an owner and handed to an
|
||||
* agent operator out-of-band): when the account whose pubkey equals a stored key
|
||||
* authenticates (NIP-42) to a Buzz-dialect relay, the auth coordinator attaches the
|
||||
* matching [OwnerAttestation.toTag] to the AUTH event, and the relay grants virtual
|
||||
* membership while the owner stays a member.
|
||||
* The counterpart of issuance ([OwnerAttestation] is signed by an owner and handed to an agent
|
||||
* operator out-of-band): when this account authenticates (NIP-42) to a Buzz-dialect relay, the auth
|
||||
* coordinator attaches [authTag] to the AUTH event, and the relay grants virtual membership while
|
||||
* the owner stays a member.
|
||||
*
|
||||
* Keyed by the **agent** pubkey (the key the attestation authorizes). Only a
|
||||
* [OwnerAttestation.verify]-passing attestation for that key should be stored, so the
|
||||
* store never carries a credential the relay would reject.
|
||||
*
|
||||
* Like [BuzzRelayDialect] this is a process-wide singleton, and — for now — in-memory
|
||||
* only: a held attestation is re-pasted after a process restart. Persisting it across
|
||||
* launches (per-account, encrypted) is a follow-up.
|
||||
* **One instance per account** (`Account.buzzAttestation`), holding at most one attestation — the
|
||||
* one issued to [agentPubKey]. It was a process-wide `Map<agentPubKey, OwnerAttestation>`, but every
|
||||
* caller only ever read or wrote the entry for the account doing the AUTH, so the map was a
|
||||
* single-entry map with a lookup that could not miss. Owning the agent key here also lets [put]
|
||||
* enforce the verification its callers used to be told to perform, which is the property that
|
||||
* matters: the store never carries a credential the relay would reject.
|
||||
*/
|
||||
object BuzzHeldAttestations {
|
||||
private val heldByAgent = MutableStateFlow<Map<HexKey, OwnerAttestation>>(emptyMap())
|
||||
class BuzzHeldAttestations(
|
||||
private val agentPubKey: HexKey,
|
||||
) {
|
||||
private val held = MutableStateFlow<OwnerAttestation?>(null)
|
||||
|
||||
/** All held attestations, keyed by agent pubkey; UI can collect this. */
|
||||
val flow: StateFlow<Map<HexKey, OwnerAttestation>> = heldByAgent
|
||||
|
||||
/** The attestation held for [agentPubKey], or null. */
|
||||
fun attestationFor(agentPubKey: HexKey): OwnerAttestation? = heldByAgent.value[agentPubKey]
|
||||
/** The attestation held for this account, or null. UI collects this. */
|
||||
val flow: StateFlow<OwnerAttestation?> = held
|
||||
|
||||
/**
|
||||
* The `auth` tag to attach to [agentPubKey]'s NIP-42 AUTH event, or null when no
|
||||
* verified attestation is held for that key.
|
||||
* The `auth` tag to attach to this account's NIP-42 AUTH event, or null when no verified
|
||||
* attestation is held.
|
||||
*/
|
||||
fun authTagFor(agentPubKey: HexKey): Array<String>? = attestationFor(agentPubKey)?.toTag()
|
||||
fun authTag(): Array<String>? = held.value?.toTag()
|
||||
|
||||
/**
|
||||
* Stores [attestation] as authorizing [agentPubKey]. The caller must have already
|
||||
* confirmed `attestation.verify(agentPubKey)`; this is a CAS-loop put so concurrent
|
||||
* writers don't clobber each other.
|
||||
* Stores [attestation] as authorizing this account, if it verifies for [agentPubKey]. Returns
|
||||
* false — storing nothing — when it does not.
|
||||
*
|
||||
* The check lives here rather than in the caller so it cannot be skipped: this is the single
|
||||
* door into the store, used by the paste flow and by the on-disk restore alike, so a tampered
|
||||
* saved credential is dropped by the same gate that rejects a mistyped one.
|
||||
*/
|
||||
fun put(
|
||||
agentPubKey: HexKey,
|
||||
attestation: OwnerAttestation,
|
||||
) {
|
||||
while (true) {
|
||||
val current = heldByAgent.value
|
||||
if (current[agentPubKey] == attestation) return
|
||||
if (heldByAgent.compareAndSet(current, current + (agentPubKey to attestation))) return
|
||||
}
|
||||
fun put(attestation: OwnerAttestation): Boolean {
|
||||
if (!attestation.verify(agentPubKey)) return false
|
||||
held.value = attestation
|
||||
return true
|
||||
}
|
||||
|
||||
/** Removes any attestation held for [agentPubKey]. */
|
||||
fun remove(agentPubKey: HexKey) {
|
||||
while (true) {
|
||||
val current = heldByAgent.value
|
||||
if (agentPubKey !in current) return
|
||||
if (heldByAgent.compareAndSet(current, current - agentPubKey)) return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the whole store with [entries] — used to restore from disk at startup. The
|
||||
* caller must have re-verified each attestation against its agent key (the same gate
|
||||
* [put] documents), so a tampered on-disk credential can't be reinstated.
|
||||
*/
|
||||
fun restore(entries: Map<HexKey, OwnerAttestation>) {
|
||||
heldByAgent.value = entries
|
||||
}
|
||||
|
||||
/** Test-only: clears all held attestations so unit tests don't leak state. */
|
||||
fun clearForTesting() {
|
||||
heldByAgent.value = emptyMap()
|
||||
/** Drops the held attestation. */
|
||||
fun clear() {
|
||||
held.value = null
|
||||
}
|
||||
}
|
||||
|
||||
+12
-9
@@ -35,11 +35,19 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
* join event to key off — so this joined set is the client's own bookkeeping of which relays
|
||||
* to sync as workspaces.
|
||||
*
|
||||
* Persisted across launches by the platform (`BuzzWorkspacePreferences` on Android mirrors it
|
||||
* to a device-global store and restores it at startup). Like [BuzzRelayDialect] it is a
|
||||
* process-wide singleton; joining also marks the relay as a Buzz dialect.
|
||||
* **One instance per account** (`Account.buzzWorkspaces`) — deliberately NOT a process-wide
|
||||
* singleton like [BuzzRelayDialect]. Joining is a per-user act: the invite was redeemed by one
|
||||
* key and the relay grants membership to that key alone. While this was device-global it also
|
||||
* fed `AuthCoordinator.isFirstParty`, so one account joining a workspace made *every* logged-in
|
||||
* account first-party there — the bystander-account AUTH leak the per-account gate exists to
|
||||
* prevent. The dialect mark stays global: which protocol a relay speaks is a property of the
|
||||
* relay, not of who is asking.
|
||||
*
|
||||
* Persisted across launches by the platform (`BuzzWorkspacePreferences` on Android mirrors each
|
||||
* account's set to that account's own store and restores it at startup). Joining also marks the
|
||||
* relay as a Buzz dialect.
|
||||
*/
|
||||
object BuzzWorkspaces {
|
||||
class BuzzWorkspaces {
|
||||
private val joined = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
|
||||
/** The joined workspace relays; discovery subscriptions and the workspaces hub collect this. */
|
||||
@@ -74,9 +82,4 @@ object BuzzWorkspaces {
|
||||
relays.forEach { BuzzRelayDialect.mark(it) }
|
||||
joined.value = relays
|
||||
}
|
||||
|
||||
/** Test-only: clears the joined set so unit tests don't leak state into each other. */
|
||||
fun clearForTesting() {
|
||||
joined.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
|
||||
@@ -193,6 +194,26 @@ class ConcordCommunitySession(
|
||||
*/
|
||||
private val editionByWrapId = HashMap<HexKey, ControlEdition?>()
|
||||
|
||||
/**
|
||||
* Guestbook wraps opened into entries, memoized by wrap id — the guestbook analogue of
|
||||
* [editionByWrapId]. [refoldGuestbook] runs on *every* arriving guestbook wrap and re-projects
|
||||
* the whole buffer, so without this the nth arrival re-opens all n wraps and a boot costs
|
||||
* O(n^2) envelope opens (each = two NIP-44 decrypts + two signature verifies). Measured on a
|
||||
* cold start: 6,229 opens over 448 distinct wraps, ~all of the app's NIP-44 traffic.
|
||||
*
|
||||
* Safe to key on wrap id alone: [guestbookKey] is derived once at construction from the
|
||||
* session's epoch and never rotates in place (a rekey builds a new session).
|
||||
*/
|
||||
private val guestbookEntryByWrapId = HashMap<HexKey, GuestbookEntry?>()
|
||||
|
||||
/**
|
||||
* Envelope opens [refoldGuestbook] actually performed (cache misses). Exposed so a test can
|
||||
* assert the fold stays linear in arrivals; a regression to re-opening the buffer shows up here
|
||||
* as O(n^2) long before it shows up as a slow boot.
|
||||
*/
|
||||
internal var guestbookOpens = 0
|
||||
private set
|
||||
|
||||
// Prior-epoch Control Plane address -> (wrapId -> wrap). Kept apart from [controlWraps]: these
|
||||
// never join the live fold, they only produce the anti-rollback floor.
|
||||
private val historicalControlWraps = HashMap<HexKey, LinkedHashMap<HexKey, Event>>()
|
||||
@@ -607,8 +628,16 @@ class ConcordCommunitySession(
|
||||
|
||||
private fun refoldGuestbook() {
|
||||
lock.withLock {
|
||||
val wraps = guestbookWraps.values.toList()
|
||||
_members.value = ConcordActions.guestbookMembers(wraps, guestbookKey)
|
||||
val entries =
|
||||
guestbookWraps.values.mapNotNull { wrap ->
|
||||
if (guestbookEntryByWrapId.containsKey(wrap.id)) {
|
||||
guestbookEntryByWrapId[wrap.id]
|
||||
} else {
|
||||
guestbookOpens++
|
||||
ConcordActions.guestbookEntry(wrap, guestbookKey).also { guestbookEntryByWrapId[wrap.id] = it }
|
||||
}
|
||||
}
|
||||
_members.value = ConcordActions.projectGuestbook(entries)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+77
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
|
||||
@@ -134,3 +135,79 @@ fun cashuWalletFilters(
|
||||
|
||||
return ownedSubs + inboundSubs
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter used to **page** the account's whole proof set back from a relay,
|
||||
* as opposed to [cashuWalletFilters], which opens a live subscription.
|
||||
*
|
||||
* The live subscription sends one REQ with no `limit` and takes whatever the
|
||||
* relay decides to give back. Relays cap an unbounded REQ (NIP-11
|
||||
* `limitation.max_limit`, or a hard-coded default) and serve the **newest**
|
||||
* events within that cap, so a wallet whose kind:7375 events are outnumbered
|
||||
* by its kind:7376 history — which is every wallet after a few hundred
|
||||
* transactions — silently receives only a suffix of its proofs. Everything
|
||||
* downstream (balance, per-mint balances, coin selection) is a pure function
|
||||
* of that suffix, which is why two devices on the same account can show two
|
||||
* different balances and neither is right.
|
||||
*
|
||||
* There is no way to detect the truncation from the REQ itself: a capped
|
||||
* response and a complete one both just EOSE. The only fix is to not rely on
|
||||
* one REQ — hand this to `fetchAllPages` / `fetchAllPagesFromPool`, which
|
||||
* walks `until` cursors until a page comes back empty and thereby reaches
|
||||
* events of any age regardless of the cap.
|
||||
*
|
||||
* Scoped to kind:7375 alone. Those are the events that carry money; history,
|
||||
* quotes and recommendations are display-only, and paging them too would
|
||||
* multiply the download for a wallet with a long history without changing a
|
||||
* single balance. A caller that wants the rest — a headless client with no
|
||||
* scrolling list to page for it — asks for [cashuOwnEventBackfillFilters].
|
||||
*/
|
||||
fun cashuProofBackfillFilters(pubkey: HexKey): List<Filter> = cashuOwnEventBackfillFilters(pubkey, listOf(CashuTokenEvent.KIND))
|
||||
|
||||
/**
|
||||
* Every NIP-60/87 kind this account authors, for a paged walk over the relays it
|
||||
* publishes to. The read-side twin of the `authors=` half of [cashuWalletFilters],
|
||||
* minus the live subscription's cap exposure.
|
||||
*/
|
||||
val OWN_CASHU_KINDS =
|
||||
listOf(
|
||||
CashuWalletEvent.KIND,
|
||||
CashuTokenEvent.KIND,
|
||||
CashuSpendingHistoryEvent.KIND,
|
||||
CashuMintQuoteEvent.KIND,
|
||||
NutzapInfoEvent.KIND,
|
||||
MintRecommendationEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* A paged backfill of the account's **own** NIP-60/87 events, over the relays it
|
||||
* publishes to. Defaults to every kind it authors; pass a narrower [kinds] to
|
||||
* page only part of it (see [cashuProofBackfillFilters]).
|
||||
*
|
||||
* Hand this to `fetchAllPages` / `fetchAllPagesFromPool`, never to a plain REQ:
|
||||
* the whole point is walking `until` cursors past the relay's cap, which is what
|
||||
* silently truncates the single uncapped REQ [cashuWalletFilters] opens.
|
||||
*/
|
||||
fun cashuOwnEventBackfillFilters(
|
||||
pubkey: HexKey,
|
||||
kinds: List<Int> = OWN_CASHU_KINDS,
|
||||
): List<Filter> =
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
authors = listOf(pubkey),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* A paged backfill of inbound NIP-61 nutzaps (kind:9321) addressed to this
|
||||
* account, matched by the recipient `#p` tag because someone else authored them.
|
||||
* Read from the account's inbox set, mirroring the split in [cashuWalletFilters].
|
||||
*/
|
||||
fun cashuInboundNutzapBackfillFilters(pubkey: HexKey): List<Filter> =
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(NutzapEvent.KIND),
|
||||
tags = mapOf("p" to listOf(pubkey)),
|
||||
),
|
||||
)
|
||||
|
||||
+33
-4
@@ -64,7 +64,8 @@ data class RelayAuthCustomToggles(
|
||||
* there, a subscription there reads its own inbox/outbox, or the relay is in its own relay list.
|
||||
* False means the only reason we are here belongs to somebody else (another logged-in account's
|
||||
* traffic, or a followed author whose outbox happens to live here). Gates the *automatic* grants
|
||||
* only: a non-first-party challenge is never auto-allowed, but it still reaches the user as a
|
||||
* only, and only for the categories it can gate without emptying them (see [RelayAuthResolver]):
|
||||
* a non-first-party challenge is never auto-allowed there, but it still reaches the user as a
|
||||
* prompt rather than a silent denial.
|
||||
*/
|
||||
data class RelayAuthInputs(
|
||||
@@ -98,12 +99,17 @@ data class RelayAuthInputs(
|
||||
* else fall through
|
||||
* 5. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY.
|
||||
*
|
||||
* The [RelayAuthPolicy.CUSTOM] grant additionally requires [RelayAuthInputs.isFirstParty]: under
|
||||
* Most [RelayAuthPolicy.CUSTOM] grants additionally require [RelayAuthInputs.isFirstParty]: under
|
||||
* "decide per relay" an account never reveals its identity *without being asked* on a relay it has no
|
||||
* reason of its own to be on, which is what keeps a bystander account off a relay only another account
|
||||
* uses. It deliberately does not suppress the question — a non-first-party challenge we can explain
|
||||
* falls through to ASK, so the user decides rather than getting a silent denial they never see.
|
||||
*
|
||||
* [RelayAuthCustomToggles.readFollows] is the one category exempt from that gate, because the gate is
|
||||
* unsatisfiable there rather than merely strict: reading a followed author means talking to *their*
|
||||
* outbox relay, which is by definition not one we publish to, subscribe to for our own inbox, or list.
|
||||
* See [customAllows].
|
||||
*
|
||||
* [RelayAuthPolicy.ALWAYS] is NOT gated this way: it means what it says, every relay that asks. Users
|
||||
* who want the narrower "only the relays I actually use" behaviour choose CUSTOM.
|
||||
*/
|
||||
@@ -131,14 +137,37 @@ object RelayAuthResolver {
|
||||
// a large follow list, produced a prompt for each of the 250+ third-party outbox relays.
|
||||
RelayAuthPolicy.ALWAYS -> RelayAuthVerdict.ALLOW
|
||||
RelayAuthPolicy.CUSTOM ->
|
||||
if (inputs.isFirstParty && customAllows(inputs)) RelayAuthVerdict.ALLOW else fallThrough(inputs)
|
||||
if (customAllows(inputs)) RelayAuthVerdict.ALLOW else fallThrough(inputs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an enabled [RelayAuthCustomToggles] category covers this relay.
|
||||
*
|
||||
* [RelayAuthCustomToggles.readFollows] is checked *before* the [RelayAuthInputs.isFirstParty]
|
||||
* gate because that gate is unsatisfiable for it, not merely strict. "I'm reading someone I
|
||||
* follow" describes their outbox relay: not one we publish to, not one serving our own
|
||||
* inbox/outbox, not one on our list — so `isFirstParty` is false by construction and gating the
|
||||
* category made it unreachable. Every follow's outbox relay prompted even with the toggle on, and
|
||||
* the only challenges it ever granted were ones `myRelaysAndVenues` already covered.
|
||||
*
|
||||
* Exempting it is safe in the way the gate is meant to be: the follow graph consulted is *this*
|
||||
* account's, so no other account's traffic can conjure a match. What it can match is another
|
||||
* logged-in account reading an author we follow too — and the cost of that is an AUTH on a relay
|
||||
* we would be reading that same author from anyway, which is what the toggle asks for.
|
||||
*
|
||||
* Every other category keeps the gate, where it costs them nothing: our own relay list and our
|
||||
* joined rooms' hosts are first-party by definition, and a pending event of ours makes its
|
||||
* destination first-party too. That is precisely what stops a bystander account being
|
||||
* auto-authenticated — and billed — on a paid inbox relay because *another* account's outgoing
|
||||
* DM happens to name someone we follow.
|
||||
*/
|
||||
private fun customAllows(inputs: RelayAuthInputs): Boolean {
|
||||
val t = inputs.toggles
|
||||
if (t.readFollows && inputs.servesFollowedReadCounterparty) return true
|
||||
if (!inputs.isFirstParty) return false
|
||||
|
||||
return (t.myRelaysAndVenues && (inputs.isInMyRelayList || inputs.servesTrustedVenue)) ||
|
||||
(t.readFollows && inputs.servesFollowedReadCounterparty) ||
|
||||
(t.messageFollows && inputs.servesFollowedWriteCounterparty) ||
|
||||
(t.messageStrangers && inputs.servesStrangerWriteCounterparty)
|
||||
}
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* 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.buzz
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuzzChannelInvitesTest {
|
||||
private val me = "a".repeat(64)
|
||||
private val stranger = "b".repeat(64)
|
||||
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
|
||||
|
||||
private fun added(
|
||||
channelId: String,
|
||||
actor: String? = stranger,
|
||||
createdAt: Long = 1_000L,
|
||||
) = MembershipNotice("add-" + channelId + "-" + createdAt, channelId, relay, actor, createdAt, removed = false)
|
||||
|
||||
private fun removed(
|
||||
channelId: String,
|
||||
actor: String? = stranger,
|
||||
createdAt: Long = 2_000L,
|
||||
) = MembershipNotice("del-" + channelId + "-" + createdAt, channelId, relay, actor, createdAt, removed = true)
|
||||
|
||||
/** Every channel is a plain named channel unless a test says otherwise. */
|
||||
private val allNamed = { _: String, _: NormalizedRelayUrl -> ChannelClassification.NAMED }
|
||||
|
||||
private fun invites(
|
||||
notices: List<MembershipNotice>,
|
||||
dismissed: Set<String> = emptySet(),
|
||||
joined: Set<String> = emptySet(),
|
||||
classify: (String, NormalizedRelayUrl) -> ChannelClassification = allNamed,
|
||||
) = BuzzChannelInvites.pendingInvites(me, notices, dismissed, joined, classify)
|
||||
|
||||
@Test
|
||||
fun anAddBySomebodyElseIsAnInvite() {
|
||||
val result = invites(listOf(added("chan-1")))
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("chan-1", result[0].channelId)
|
||||
assertEquals(stranger, result[0].actor)
|
||||
assertEquals(relay, result[0].relay)
|
||||
assertEquals(1_000L, result[0].createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSelfJoinIsNotAnInvite() {
|
||||
assertTrue(invites(listOf(added("chan-1", actor = me))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSelfJoinIsMatchedCaseInsensitively() {
|
||||
assertTrue(invites(listOf(added("chan-1", actor = me.uppercase()))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAddWithNoReadableActorIsStillAnInvite() {
|
||||
// The relay body can be missing or malformed. "Somebody put me here and I can't tell who" is
|
||||
// still a question for the viewer — the card renders an unknown-actor row for exactly this.
|
||||
val result = invites(listOf(added("chan-1", actor = null)))
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(null, result[0].actor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRemovalSupersedesAnEarlierAdd() {
|
||||
assertTrue(invites(listOf(added("chan-1", createdAt = 1_000L), removed("chan-1", createdAt = 2_000L))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAddAfterARemovalIsAnInviteAgain() {
|
||||
val result =
|
||||
invites(
|
||||
listOf(
|
||||
added("chan-1", createdAt = 1_000L),
|
||||
removed("chan-1", createdAt = 2_000L),
|
||||
added("chan-1", createdAt = 3_000L),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(3_000L, result[0].createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderOfArrivalDoesNotChangeTheAnswer() {
|
||||
// A re-subscribe replays the relay's whole history, and nothing guarantees the order it comes
|
||||
// back in. The projection resolves by created_at, so both orderings agree.
|
||||
val chronological = listOf(added("chan-1", createdAt = 1_000L), removed("chan-1", createdAt = 2_000L))
|
||||
|
||||
assertEquals(
|
||||
invites(chronological).map { it.channelId },
|
||||
invites(chronological.reversed()).map { it.channelId },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTieResolvesToTheRemoval() {
|
||||
assertTrue(invites(listOf(added("chan-1", createdAt = 5L), removed("chan-1", createdAt = 5L))).isEmpty())
|
||||
assertTrue(invites(listOf(removed("chan-1", createdAt = 5L), added("chan-1", createdAt = 5L))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun redeliveringTheSameNoticeIsIdempotent() {
|
||||
// The regression this projection exists for: the old registry re-recorded an invite that
|
||||
// classification had already withdrawn, so the prompt flickered on every re-delivery.
|
||||
val once = invites(listOf(added("chan-1")))
|
||||
val twice = invites(listOf(added("chan-1"), added("chan-1")))
|
||||
|
||||
assertEquals(once.map { it.channelId }, twice.map { it.channelId })
|
||||
assertEquals(1, twice.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDismissedChannelIsWithheld() {
|
||||
assertTrue(invites(listOf(added("chan-1")), dismissed = setOf("chan-1")).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aJoinedChannelIsWithheld() {
|
||||
assertTrue(invites(listOf(added("chan-1")), joined = setOf("chan-1")).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDmIsNeverAnInvite() {
|
||||
assertTrue(invites(listOf(added("chan-1"))) { _, _ -> ChannelClassification.DM }.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnclassifiedChannelIsWithheldRatherThanGuessed() {
|
||||
// A DM arrives as the same kind-44100 as a channel add. Surfacing before the kind-39000 lands
|
||||
// would flash a "somebody added you to a channel" card for every new DM and then withdraw it.
|
||||
assertTrue(invites(listOf(added("chan-1"))) { _, _ -> ChannelClassification.UNKNOWN }.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invitesComeBackNewestFirst() {
|
||||
val result =
|
||||
invites(
|
||||
listOf(
|
||||
added("older", createdAt = 1_000L),
|
||||
added("newest", createdAt = 3_000L),
|
||||
added("middle", createdAt = 2_000L),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("newest", "middle", "older"), result.map { it.channelId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInviteCarriesTheEventItCameFrom() {
|
||||
// The Notifications feed keys its card on this — it is the identity that lets an invite dedup,
|
||||
// scroll-to and page like every other notification row.
|
||||
val invite = invites(listOf(added("chan-1", createdAt = 7L))).single()
|
||||
|
||||
assertEquals("add-chan-1-7", invite.eventId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingByEventIdIsKeyedForThePerNoteLookup() {
|
||||
// `NotificationFeedFilter.acceptableEvent` runs per note, so it needs this as a map rather than
|
||||
// a scan: a cached 44100 is a live question exactly when its id is a key here.
|
||||
val byId =
|
||||
BuzzChannelInvites.pendingInvitesByEventId(
|
||||
viewer = me,
|
||||
notices = listOf(added("chan-1", createdAt = 7L), added("chan-2", createdAt = 8L)),
|
||||
dismissed = setOf("chan-2"),
|
||||
joined = emptySet(),
|
||||
classify = allNamed,
|
||||
)
|
||||
|
||||
assertEquals(setOf("add-chan-1-7"), byId.keys)
|
||||
assertEquals("chan-1", byId["add-chan-1-7"]?.channelId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSupersededAddDropsOutOfThePerNoteLookup() {
|
||||
// The 44100 stays in the cache forever, so the accept gate has to answer "no" for one the relay
|
||||
// has since withdrawn — otherwise the card would outlive the membership.
|
||||
val byId =
|
||||
BuzzChannelInvites.pendingInvitesByEventId(
|
||||
viewer = me,
|
||||
notices = listOf(added("chan-1", createdAt = 1L), removed("chan-1", createdAt = 2L)),
|
||||
dismissed = emptySet(),
|
||||
joined = emptySet(),
|
||||
classify = allNamed,
|
||||
)
|
||||
|
||||
assertTrue(byId.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentMembershipsCoverEveryChannelRegardlessOfTypeOrActor() {
|
||||
// The directory fetch iterates this, and a channel's type is only knowable once its kind-39000
|
||||
// has been fetched BY ID — so the set it works from cannot already be filtered by type.
|
||||
val memberships =
|
||||
BuzzChannelInvites.currentMemberships(
|
||||
listOf(
|
||||
added("named"),
|
||||
added("dm"),
|
||||
added("self-joined", actor = me),
|
||||
added("left", createdAt = 1_000L),
|
||||
removed("left", createdAt = 2_000L),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(setOf("named", "dm", "self-joined"), memberships.keys)
|
||||
assertEquals(relay, memberships["named"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun latestPerChannelKeepsChannelsApart() {
|
||||
val newest =
|
||||
BuzzChannelInvites.latestPerChannel(
|
||||
listOf(
|
||||
added("chan-1", createdAt = 1_000L),
|
||||
added("chan-2", createdAt = 500L),
|
||||
removed("chan-1", createdAt = 3_000L),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(setOf("chan-1", "chan-2"), newest.keys)
|
||||
assertTrue(newest["chan-1"]!!.removed)
|
||||
assertEquals(500L, newest["chan-2"]!!.createdAt)
|
||||
}
|
||||
}
|
||||
+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.amethyst.commons.model.buzz
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuzzChannelStarsTest {
|
||||
// A fresh instance per test: stars are per-account state now, not a process-wide singleton.
|
||||
private val stars = BuzzChannelStars()
|
||||
|
||||
private val a = "channel-a"
|
||||
private val b = "channel-b"
|
||||
|
||||
@Test
|
||||
fun toggleStarsAndUnstars() {
|
||||
assertFalse(stars.isStarred(a))
|
||||
assertTrue(stars.toggle(a))
|
||||
assertTrue(stars.isStarred(a))
|
||||
assertEquals(setOf(a), stars.flow.value)
|
||||
|
||||
assertFalse(stars.toggle(a))
|
||||
assertFalse(stars.isStarred(a))
|
||||
assertEquals(emptySet(), stars.flow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreReplacesTheWholeSet() {
|
||||
stars.toggle(a)
|
||||
stars.restore(setOf(b))
|
||||
assertEquals(setOf(b), stars.flow.value)
|
||||
assertFalse(stars.isStarred(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneAccountsStarsDoNotPinForAnother() {
|
||||
// Why this is per account: a star reorders and badges the community channel list. While the
|
||||
// set was a process-wide singleton, one account pinning a channel reordered every other
|
||||
// logged-in account's list, and switching accounts rewrote the set they shared.
|
||||
val mine = BuzzChannelStars()
|
||||
val theirs = BuzzChannelStars()
|
||||
|
||||
mine.toggle(a)
|
||||
|
||||
assertTrue(mine.isStarred(a))
|
||||
assertFalse(theirs.isStarred(a))
|
||||
assertEquals(emptySet(), theirs.flow.value)
|
||||
}
|
||||
}
|
||||
+38
@@ -65,4 +65,42 @@ class BuzzDmChannelsTest {
|
||||
assertEquals(mapOf("chan-1" to relayA), BuzzDmChannels.channelsFor(alice))
|
||||
assertEquals(emptyMap(), BuzzDmChannels.channelsFor(bob))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceDeclaresTheWholeSet() {
|
||||
BuzzDmChannels.record(alice, "gone", relayA)
|
||||
|
||||
assertTrue(BuzzDmChannels.replace(alice, mapOf("kept" to relayB)))
|
||||
assertEquals(mapOf("kept" to relayB), BuzzDmChannels.channelsFor(alice))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceWithTheSameSetIsANoOpAndDoesNotChurn() {
|
||||
// Discovery recomputes from the cache on every pass, so most passes declare a set that is
|
||||
// already current. Those must not emit — a churning flow would re-trigger every collector,
|
||||
// which is what the incremental record/remove version did on a loop.
|
||||
BuzzDmChannels.replace(alice, mapOf("chan-1" to relayA))
|
||||
val before = BuzzDmChannels.flow.value
|
||||
|
||||
assertFalse(BuzzDmChannels.replace(alice, mapOf("chan-1" to relayA)))
|
||||
assertTrue(before === BuzzDmChannels.flow.value, "the flow instance is unchanged on a no-op")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceWithAnEmptySetClearsTheViewer() {
|
||||
BuzzDmChannels.replace(alice, mapOf("chan-1" to relayA))
|
||||
|
||||
assertTrue(BuzzDmChannels.replace(alice, emptyMap()))
|
||||
assertEquals(emptyMap(), BuzzDmChannels.channelsFor(alice))
|
||||
assertFalse(BuzzDmChannels.replace(alice, emptyMap()), "clearing an already-empty viewer is a no-op")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceLeavesOtherViewersAlone() {
|
||||
BuzzDmChannels.replace(bob, mapOf("bobs" to relayA))
|
||||
BuzzDmChannels.replace(alice, mapOf("alices" to relayB))
|
||||
|
||||
assertEquals(mapOf("bobs" to relayA), BuzzDmChannels.channelsFor(bob))
|
||||
assertEquals(mapOf("alices" to relayB), BuzzDmChannels.channelsFor(alice))
|
||||
}
|
||||
}
|
||||
|
||||
+46
-18
@@ -21,44 +21,72 @@
|
||||
package com.vitorpamplona.amethyst.commons.model.buzz
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import kotlin.test.AfterTest
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuzzHeldAttestationsTest {
|
||||
private val agent = "a".repeat(64)
|
||||
private val owner = "b".repeat(64)
|
||||
private val attestation = OwnerAttestation(ownerPubKey = owner, conditions = "kind=40002", sig = "c".repeat(128))
|
||||
private val agentKey = KeyPair()
|
||||
private val otherKey = KeyPair()
|
||||
private val ownerKey = KeyPair()
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() = BuzzHeldAttestations.clearForTesting()
|
||||
private val agent = agentKey.pubKey.toHexKey()
|
||||
private val other = otherKey.pubKey.toHexKey()
|
||||
|
||||
private val attestation = OwnerAttestation.sign(agent, CONDITIONS, ownerKey.privKey!!)
|
||||
|
||||
// One store per account, holding the attestation issued to that account's key.
|
||||
private val held = BuzzHeldAttestations(agent)
|
||||
|
||||
@Test
|
||||
fun emptyStoreYieldsNoTag() {
|
||||
assertNull(BuzzHeldAttestations.attestationFor(agent))
|
||||
assertNull(BuzzHeldAttestations.authTagFor(agent))
|
||||
assertNull(held.flow.value)
|
||||
assertNull(held.authTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heldAttestationSurfacesAsItsAuthTag() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
assertEquals(attestation, BuzzHeldAttestations.attestationFor(agent))
|
||||
assertTrue(held.put(attestation))
|
||||
assertEquals(attestation, held.flow.value)
|
||||
// The tag attached to the agent's AUTH is exactly the attestation's ["auth", …] tag.
|
||||
assertContentEquals(attestation.toTag(), BuzzHeldAttestations.authTagFor(agent))
|
||||
assertContentEquals(attestation.toTag(), held.authTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeClearsTheHeldAttestation() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
BuzzHeldAttestations.remove(agent)
|
||||
assertNull(BuzzHeldAttestations.authTagFor(agent))
|
||||
fun clearDropsTheHeldAttestation() {
|
||||
held.put(attestation)
|
||||
held.clear()
|
||||
assertNull(held.authTag())
|
||||
assertNull(held.flow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneAgentsAttestationDoesNotLeakToAnother() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
assertNull(BuzzHeldAttestations.authTagFor("d".repeat(64)))
|
||||
fun anAttestationIssuedToAnotherKeyIsRejected() {
|
||||
// The check that used to be the caller's job: this credential is real and verifies — for
|
||||
// somebody else's key. Storing it would attach an `auth` tag the relay rejects, and the
|
||||
// store's whole contract is that it never holds one.
|
||||
val theirs = BuzzHeldAttestations(other)
|
||||
|
||||
assertFalse(theirs.put(attestation))
|
||||
assertNull(theirs.authTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTamperedAttestationIsRejectedAndLeavesTheHeldOneIntact() {
|
||||
held.put(attestation)
|
||||
|
||||
val forged = attestation.copy(conditions = "kind=1")
|
||||
|
||||
assertFalse(held.put(forged))
|
||||
assertEquals(attestation, held.flow.value)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CONDITIONS = "kind=40002"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user