mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
docs(nip29): plan to split group chat into always-on state + paginated content
Design doc for consolidating the six overlapping NIP-29 group-chat REQ assemblers into the same state-vs-content shape the DM and Concord chat stacks already use: an always-on account subscription for the small replaceable state (metadata / roster / roles / pins), and a live tail + on-demand backward history pager (reusing BackwardRelayPager / RelayLoadingCursors / WindowLoadTracker) for the high-volume chat content. Includes the full message-delivery coverage proof (can't-miss-messages), the file-by-file change list, and the additive-first rollout order. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# NIP-29 group-chat subscriptions: split *state* (always-on) from *content* (paginated)
|
||||
|
||||
**Status:** proposed · **Date:** 2026-07-18 · **Module:** `amethyst` (+ `commons` model, reuses `commons`/`quartz` paging)
|
||||
|
||||
## Problem
|
||||
|
||||
NIP-29 relay-group ("RelayGroup") chat is served today by **six** overlapping
|
||||
REQ assemblers, each keyed differently and each re-deriving the same two queries:
|
||||
|
||||
| Query shape | Emitted by (today) |
|
||||
|---|---|
|
||||
| Metadata `#d` (39000–39005 + pins) | Warmup, ChannelPublic (open), MyJoinedGroups (roster subset), OnRelay (directory) |
|
||||
| Content `#h` (kind 9 + poll) | MyJoinedGroups (limit 50), Warmup (limit 50), ChannelPublic-open (limit 200) |
|
||||
| My-own `#h` (`authors=[me]`) | ChannelFromUser — **redundant for groups** (the all-authors `#h` window already returns my messages; a group is pinned to one host relay) |
|
||||
| Threads `#h` (11 + 1111) | Warmup, ThreadFeed |
|
||||
|
||||
Two concrete defects fall out of this shape:
|
||||
|
||||
1. **Slow / partial first load** (the reported bug). Content is fetched in **fixed
|
||||
windows** (limit 50 / 200) gated by a *shared per-relay* `since`
|
||||
(`RelayGroupMyJoinedGroupsSubAssembler` is keyed by `Account`, so its `since`
|
||||
collapses to one map per relay, not per group). A group joined or surfaced
|
||||
after that relay's `since` advanced never backfills; opening it waits a full
|
||||
relay round-trip.
|
||||
2. **We can miss messages.** A fixed `limit=200` window has no way to reach older
|
||||
history, and no demand-driven paging: scroll up past 200 and there is nothing
|
||||
behind it. There is also a **serving-relay keying hazard** (see below) where a
|
||||
referenced group message lands in a channel the UI never reads.
|
||||
|
||||
Every *other* chat surface in the app already solved this with a **two-subscription
|
||||
model** — an always-on live tail + an on-demand backward history pager — and there
|
||||
is a reusable framework for it. Group chat is the outlier that never adopted it.
|
||||
|
||||
## Goal
|
||||
|
||||
Split group chat into the same shape every other chat uses, and delete the
|
||||
duplication:
|
||||
|
||||
- **State** (metadata / roster / roles / pins) — small replaceable events →
|
||||
**one always-on account subscription**, gated on the NIP-29 settings toggle.
|
||||
The cache is always current; no per-screen metadata re-fetch.
|
||||
- **Content** (kind 9 chat + polls) — high volume → **live tail + backward history
|
||||
pager**, exactly like NIP-04 DMs and Concord channels. Gap-proof (`RelayLoadingCursors`
|
||||
handles cache-prune rewind), demand-driven by the visible feed, reconnect-safe.
|
||||
|
||||
No message path that delivers a group message today may be dropped.
|
||||
|
||||
## Reused framework (do not reimplement)
|
||||
|
||||
Mapped end-to-end from the NIP-04 DM stack and the **Concord channel** stack, which
|
||||
is the closest existing template (a public group channel already paged this way):
|
||||
|
||||
| Piece | Location | Role |
|
||||
|---|---|---|
|
||||
| `BackwardRelayPager(name, pageLimit, liveTailSeconds)` | `commons/.../relayClient/paging/` | single-active per-relay backward orchestrator |
|
||||
| `RelayLoadingCursors` | `quartz/.../relay/client/paging/` | per-relay `until`/`reached`/`done` cursors + `rewindTo` (prune realign) — **one instance per group scope** |
|
||||
| `WindowLoadTracker` + `trackingListener` | `commons/.../relayClient/paging/` | live-tail "all relays settled" indicator |
|
||||
| `PagingStatus`, `RelayPagingProgress` | paging pkg / quartz | atomic display snapshot |
|
||||
| `RelayReachCursor` / `RelayReachSentinels` / `RelayReachMarkers` | `commons/.../ui/feeds/RelayReachMarker.kt` | viewport-driven "load older" markers |
|
||||
| `DmHistoryLoadingCard`, `RefreshingChatroomFeedView(olderBoundary, markersInGap, sentinels)` | `amethyst/.../chats/feed/ChatFeedView.kt` | shared feed hooks |
|
||||
| `DmHistoryTuning.recentBoundary()` | `commons/.../model/privateChats/` | shared live-tail floor (7 days) |
|
||||
|
||||
**Direct templates to copy:**
|
||||
`ConcordChannelHistorySubAssembler` + `ConcordChannelHistoryFilterAssembler` +
|
||||
`ConcordChannelHistorySubscription` + `ConcordChannelScreen`'s
|
||||
`ConcordBackfillHistoryToWindow`; and `ChatroomNip04SubAssembler` (live tail) /
|
||||
`ConcordChannelFilterAssembler` (batched always-on live).
|
||||
|
||||
## Target architecture
|
||||
|
||||
Four concerns, mirroring the DM stack (rooms-list tail + per-conversation tail +
|
||||
per-conversation history) plus a groups-only always-on state sub.
|
||||
|
||||
1. **`RelayGroupStateSubAssembler`** — *always-on*, account-keyed.
|
||||
Roster `#d` (39000/39001/39002/39003/39005) batched one filter per host relay
|
||||
across the joined set. Keeps `since` (tiny replaceable events; reconnect just
|
||||
re-confirms). Mounted at `LoggedInPage` (like `AccountFilterAssemblerSubscription`),
|
||||
gated on `ChatFeedType.NIP29`. **This is today's `RelayGroupMyJoinedGroups` roster
|
||||
path, promoted to always-on and stripped of content.**
|
||||
|
||||
2. **`RelayGroupPreviewTailSubAssembler`** — *always-on*, account-keyed, batched.
|
||||
Content `#h` (kind 9 + poll) across **all** `liveRelayGroupList` group ids,
|
||||
`since = recentBoundary()`, **no per-group limit** (a time floor bounds it, so it
|
||||
batches into one filter per relay). `WindowLoadTracker`. Drives Messages-list
|
||||
previews and keeps joined groups' recent chat live app-wide. **Replaces
|
||||
`RelayGroupMyJoinedGroups` content path (A).** Batching + time-floor `since`
|
||||
eliminates both the per-group-`since` bug and the reconnect re-download.
|
||||
|
||||
3. **`RelayGroupChatTailSubAssembler`** — per-open-`GroupId`, live tail for the
|
||||
*currently open* group: content `#h` (9 + poll), `since = recentBoundary()`, host
|
||||
relay. Covers recent + live updates for **any** open group, **including non-joined**
|
||||
groups opened by link (which the batched preview tail — joined-only — doesn't cover).
|
||||
Mirrors the DM per-conversation live tail.
|
||||
|
||||
4. **`RelayGroupChatHistorySubAssembler`** — per-open-`GroupId`, `BackwardRelayPager`
|
||||
(`liveTailSeconds` = 7d floor; the tails cover above it), cursors on
|
||||
`RelayGroupChannel.history`, content `#h` (9 + poll, **all authors**) `until`+`limit`
|
||||
on the host relay. Demand-driven by the feed markers; eager `advanceAll()` backfill
|
||||
to a window target on open. **Replaces ChannelPublic-open content (C) and
|
||||
ChannelFromUser (D).** All-authors, so it re-materializes my own history too.
|
||||
|
||||
`ChannelFeedFilter` is unchanged — it reads `channel.notes`, so every path that fills
|
||||
the cache surfaces. (It has **no `limit()`**, so it already renders whatever is cached.)
|
||||
|
||||
## Message-coverage proof (can't-miss-messages checklist)
|
||||
|
||||
Every current content-delivery path and what covers it after:
|
||||
|
||||
| Path (today) | Kinds / scope | After |
|
||||
|---|---|---|
|
||||
| **A** MyJoined content (50) | 9,poll `#h` joined | **Preview tail (batched `#h`, since=window)** for previews + **chat tail** when open |
|
||||
| **B** Warmup content (50) | 9,poll,11,1111 `#h` card | **KEEP** — non-joined cards/discovery aren't in the joined tail (screen-dependent, per design) |
|
||||
| **C** ChannelPublic-open content (200) | 9,poll `#h` open | **Chat tail (recent) + history pager (older, gap-proof)** |
|
||||
| **D** ChannelFromUser (`authors=me`) | 9,poll `#h` me | **History pager (all-authors) + tail + optimistic-send attach + host echo** → redundant |
|
||||
| **E** ThreadFeed | 11,1111 `#h` | **KEEP** (Threads screen; separate `threadNotes` feed). Pager adoption is a follow-up. |
|
||||
| **F** Notifications | 7,9,1111,1068,… `#h`+`#p=me` | **KEEP** — always-on, p-tags-me; unchanged bonus |
|
||||
| §3 by-id (`filterMissingEvents`) | ids | **KEEP** — quotes/replies/mentions; **+ serving-relay fix below** |
|
||||
| §3 pinned by-id backfill | ids `filterMetadataToRelayGroup` | **KEEP** (host relay; older-than-window pins) |
|
||||
| §3 replies/reactions `#e/#q` | 1111 etc. | **KEEP** — comments never attach to timeline (by design) |
|
||||
|
||||
**Serving-relay keying hazard (real, pre-existing — fix as part of "can't miss").**
|
||||
`attachToRelayGroupIfScoped` keys the channel by `GroupId(groupId, servingRelay)`.
|
||||
All subscriptions here are host-pinned, so they're safe. But `filterMissingEvents`
|
||||
can deliver a referenced group message from a **non-host** relay, filing it under a
|
||||
different channel object than the host-keyed one the UI reads → cached but invisible.
|
||||
Fix: when attaching a group-scoped content event, if exactly one existing
|
||||
`RelayGroupChannel` carries that `groupId` (the joined/host one), attach there
|
||||
instead of minting a `(groupId, servingRelay)` channel — reusing the existing
|
||||
`singleOrNull`-by-groupId resolution already used for the `relay == null` optimistic
|
||||
branch. Ambiguous ids (the relay-wide `_` group joined on several relays) keep
|
||||
serving-relay keying.
|
||||
|
||||
## File-by-file changes
|
||||
|
||||
**New (`commons` model):**
|
||||
- `RelayGroupChannel`: add `val history = RelayLoadingCursors()` (mirror `ConcordChannel.history`).
|
||||
|
||||
**New (`amethyst` datasource, per templates):**
|
||||
- `RelayGroupStateFilterAssembler` (+ SubAssembler) — always-on roster.
|
||||
- `RelayGroupPreviewTailFilterAssembler` (+ SubAssembler) — batched preview tail.
|
||||
- `RelayGroupChatTailFilterAssembler` (+ SubAssembler) — per-open live tail.
|
||||
- `RelayGroupChatHistoryFilterAssembler` (+ SubAssembler) — per-open history pager.
|
||||
- Subscription composables for each (`*Subscription`), copying the Concord ones.
|
||||
|
||||
**Modified:**
|
||||
- `RelaySubscriptionsCoordinator`: register the four new assemblers; drop the retired ones (see below).
|
||||
- `LoggedInPage`: mount `RelayGroupStateSubscription` + `RelayGroupPreviewTailSubscription` (always-on, gated).
|
||||
- `RelayGroupChannelView`: mount chat-tail + history subscriptions; wire
|
||||
`RefreshingChatroomFeedView(olderBoundary, markersInGap, sentinels)` + a
|
||||
`BackfillHistoryToWindow` (copy `ConcordBackfillHistoryToWindow`).
|
||||
- `MessagesSinglePane`/`MessagesTwoPane`: drop `RelayGroupMyJoinedGroupsSubscription`
|
||||
(its roster role moves to the always-on state sub; previews come from the tail).
|
||||
- `LocalCache.attachToRelayGroupIfScoped`: host-relay normalization (serving-relay fix).
|
||||
- `AccountViewModel.dataSources()`: expose the four new assemblers; remove retired handles.
|
||||
|
||||
**Retired:**
|
||||
- `RelayGroupMyJoinedGroupsFilterAssembler` **content path** → deleted; the file's
|
||||
roster role becomes `RelayGroupStateFilterAssembler` (rename/replace).
|
||||
- `ChannelPublicFilterSubAssembler` **`RelayGroupChannel` branch** (`filterMessagesToRelayGroup`
|
||||
+ `filterMetadataToRelayGroup`) → removed; metadata now always-on, content now tail+pager.
|
||||
(Keep `filterMetadataToRelayGroup`'s **pinned-id backfill** — re-home it on the chat-tail or a
|
||||
small pin sub so older-than-window pins still resolve.)
|
||||
- `ChannelFromUserFilterSubAssembler` **`RelayGroupChannel` branch** (`filterMyMessagesToRelayGroup`) → removed.
|
||||
- Keep `RelayGroupWarmup*` (non-joined cards), `RelayGroupsOnRelay*` (directory),
|
||||
`RelayGroupsDiscovery*` (discover feed), `RelayGroupThreadFeed*` (threads), and the
|
||||
notifications path unchanged.
|
||||
|
||||
## Rollout order (additive first, retire last — never a window where messages drop)
|
||||
|
||||
1. **Additive, no removals:** add `RelayGroupChannel.history`; add the four new
|
||||
assemblers + subscriptions + coordinator/dataSources handles + `LoggedInPage`
|
||||
and `RelayGroupChannelView` wiring. New content now flows through tail+pager
|
||||
**alongside** the old A/C/D (harmless dedup by id). Compile + smoke.
|
||||
2. **Serving-relay normalization** in `LocalCache` (independent correctness fix).
|
||||
3. **Retire** A-content, C-relay-group-branch, D-relay-group-branch; move roster to
|
||||
the always-on state sub; drop the Messages-pane `MyJoinedGroups` mount. Compile.
|
||||
4. Re-home the pinned-id backfill; delete now-dead code; `spotlessApply`; full suite.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Quiet group** (newest message older than the 7-day tail): won't appear in the
|
||||
preview tail; its Messages row falls back to cached / placeholder (same as NIP-04).
|
||||
Opening it → the history pager's eager backfill loads it. Optional: a one-shot
|
||||
newest-1 per quiet joined group in the state sub's initial snapshot.
|
||||
- **Non-joined open group:** covered by the per-open chat tail + history pager
|
||||
(both per-`GroupId`, no joined-list dependency).
|
||||
- **Reconnect:** tails carry `since=recentBoundary()` (time floor, shared-safe,
|
||||
incremental); history is `until`-based (position, not reconnect-sensitive);
|
||||
`FiltersChanged` already ignores `since`-only changes → no full replay.
|
||||
- **Threads:** unchanged this pass; a follow-up can point `RelayGroupThreadFeed` at
|
||||
a second `BackwardRelayPager` on `RelayGroupChannel` for kind 11/1111.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: preview-tail filter batches one `#h` filter per relay with
|
||||
`since=recentBoundary()` and no per-group limit; history filter emits only for
|
||||
armed relays at their `requestedUntil`; state filter emits roster `#d` per relay.
|
||||
- Cursor behavior is already covered by `RelayLoadingCursors` tests (reused).
|
||||
- Manual (amy / device): join a group after session start → open → history backfills;
|
||||
scroll up past the window → older pages load; reconnect → no full re-download;
|
||||
quote a group message from a non-host relay → it appears in the group.
|
||||
-55
@@ -1,55 +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.chats.publicChannels.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
/** Timeline kinds shown in a NIP-29 group: chat messages and polls. */
|
||||
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
|
||||
|
||||
/**
|
||||
* The message timeline for a NIP-29 group. A group's chat lives entirely on its
|
||||
* host relay, scoped by the `h` tag, so the filter is pinned to
|
||||
* [RelayGroupChannel.relays] (always the single host) and never fans out to the
|
||||
* user's other relays.
|
||||
*/
|
||||
fun filterMessagesToRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> =
|
||||
channel.relays().toSet().map {
|
||||
RelayBasedFilter(
|
||||
relay = it,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(channel.groupId.id)),
|
||||
limit = 200,
|
||||
since = since?.get(it)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
-57
@@ -1,57 +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.chats.publicChannels.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.service.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.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
|
||||
|
||||
/**
|
||||
* The current user's own messages in a NIP-29 group. A smaller companion to
|
||||
* [filterMessagesToRelayGroup] that back-fills the user's sent messages (so
|
||||
* optimistic sends reconcile) — pinned to the group's host relay and scoped by
|
||||
* both the `h` group tag and the author.
|
||||
*/
|
||||
fun filterMyMessagesToRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
pubKey: HexKey,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> =
|
||||
channel.relays().toSet().map {
|
||||
RelayBasedFilter(
|
||||
relay = it,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(channel.groupId.id)),
|
||||
authors = listOf(pubKey),
|
||||
limit = 50,
|
||||
since = since?.get(it)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
-204
@@ -1,204 +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.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
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.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
/** One screen's request to keep the roster of the user's joined groups fresh. */
|
||||
class RelayGroupMyJoinedGroupsQueryState(
|
||||
val account: Account,
|
||||
)
|
||||
|
||||
/**
|
||||
* Roster kinds only (39000 metadata + 39001 admins + 39002 members) — enough to
|
||||
* resolve name, member count and this user's membership. Roles (39003) are pulled
|
||||
* by the per-chat / directory subscriptions when actually needed.
|
||||
*/
|
||||
private val RELAY_GROUP_ROSTER_KINDS =
|
||||
listOf(
|
||||
GroupMetadataEvent.KIND,
|
||||
GroupAdminsEvent.KIND,
|
||||
GroupMembersEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* Timeline kinds shown in a group's chat — chat messages and polls. Kept in sync with the
|
||||
* in-group feed ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource
|
||||
* .subassemblies.filterMessagesToRelayGroup]) so the preview and the opened chat agree.
|
||||
*/
|
||||
private val RELAY_GROUP_PREVIEW_CONTENT_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
|
||||
|
||||
/**
|
||||
* How many recent chat events to prefetch per joined group. Enough for the Messages-list
|
||||
* preview to reflect the true newest message and for opening the group to land on a populated
|
||||
* first screen. Matches [RELAY_GROUP_WARMUP_LIMIT].
|
||||
*/
|
||||
private const val RELAY_GROUP_JOINED_PREVIEW_LIMIT = 50
|
||||
|
||||
/**
|
||||
* Keeps the relay-signed roster (metadata/admins/members) of every group the user
|
||||
* has joined live while a groups-bearing screen is on top, so membership,
|
||||
* pending→member transitions and member counts stay accurate in list views
|
||||
* without having to open each chat. This matters most for closed/private groups,
|
||||
* where the only way to confirm a join was admitted is a fresh 39002.
|
||||
*
|
||||
* On top of the roster it prefetches a bounded slice of each group's most recent chat
|
||||
* (kind 9 + polls), so the Messages-list preview shows the true newest message instead of
|
||||
* whatever kind-9 events happened to already be cached, and opening a group lands on
|
||||
* populated content. Without this the list would only ever surface "scattered" messages
|
||||
* that arrived through unrelated subscriptions until the group was actually opened.
|
||||
*
|
||||
* Roster is one `#d`-scoped filter per host relay (only what we're in, not the relay's whole
|
||||
* directory); content is one `#h`-scoped, limited filter per group (a per-filter limit can't be
|
||||
* shared across groups, and `#d`/`#h` can't be merged into a single filter).
|
||||
*/
|
||||
class RelayGroupMyJoinedGroupsFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<RelayGroupMyJoinedGroupsQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
RelayGroupMyJoinedGroupsSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
|
||||
class RelayGroupMyJoinedGroupsSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<RelayGroupMyJoinedGroupsQueryState>,
|
||||
) : PerUniqueIdEoseManager<RelayGroupMyJoinedGroupsQueryState, Account>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: RelayGroupMyJoinedGroupsQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>? {
|
||||
if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP29)) return null
|
||||
val joined = key.account.relayGroupList.liveRelayGroupList.value
|
||||
if (joined.isEmpty()) return null
|
||||
|
||||
// Group the joined group ids by their host relay: one #d-scoped roster filter each. Roster
|
||||
// kinds are a handful of small replaceable events per group, so the shared per-relay `since`
|
||||
// is fine here — on a reconnect the relay just re-confirms nothing changed instead of
|
||||
// replaying a page of chat.
|
||||
val idsByRelay = joined.groupBy({ it.relayUrl }, { it.groupId })
|
||||
|
||||
val rosterFilters =
|
||||
idsByRelay.mapNotNull { (relayUrl, groupIds) ->
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_ROSTER_KINDS,
|
||||
tags = mapOf("d" to groupIds.distinct()),
|
||||
since = since?.get(relay)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// One #h-scoped, limited content slice per joined group so list previews show the true
|
||||
// newest chat and opening the group lands on cached messages. A group's #d roster id and
|
||||
// its #h message id are the same string, but #d and #h can't be merged into one filter and
|
||||
// a limit is per-filter, so this stays one bounded filter per group.
|
||||
//
|
||||
// `since` handling is per-group, sourced from the cache — NOT the raw shared per-relay EOSE.
|
||||
// This subassembler is keyed by account, so [since] is a single per-relay map shared across
|
||||
// every joined group on that relay. Applying it blindly gated a group joined (or first
|
||||
// surfaced) after that relay's EOSE advanced: it would only ever fetch events newer than that
|
||||
// timestamp, so its history never prefetched and the list showed just the newest message
|
||||
// while opening waited on a full relay round-trip. But dropping `since` entirely is just as
|
||||
// wrong: the pool re-sends every REQ on each reconnect (which happens constantly), so a
|
||||
// `since`-less filter would replay the whole page for every group on every reconnect.
|
||||
//
|
||||
// So we gate the shared `since` on whether we already hold a full page of this group's
|
||||
// preview content (kept in the Channel's strong-ref notes cache, so it survives the session):
|
||||
// - < LIMIT cached → cold/newly-joined/thinly-scattered: fetch the full page (no `since`).
|
||||
// Once the page lands it flips to the incremental branch on its own.
|
||||
// - >= LIMIT cached → already backfilled: use the shared `since` so reconnects fetch only
|
||||
// the tail. `since` being shared across groups is safe here — the group
|
||||
// already has its history, this only bounds incremental top-ups.
|
||||
// A group with genuinely fewer than LIMIT total events stays on the no-`since` branch and
|
||||
// re-pulls its (sub-page, cheap) content on reconnect — an acceptable cost for guaranteeing
|
||||
// the backfill, and far less than replaying a full page for every group.
|
||||
val contentFilters =
|
||||
joined.mapNotNull { group ->
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(group.relayUrl) ?: return@mapNotNull null
|
||||
val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(group.groupId, relay))
|
||||
val alreadyBackfilled =
|
||||
channel.notes.count { _, note ->
|
||||
note.event?.kind?.let { it in RELAY_GROUP_PREVIEW_CONTENT_KINDS } ?: false
|
||||
} >= RELAY_GROUP_JOINED_PREVIEW_LIMIT
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_PREVIEW_CONTENT_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(group.groupId)),
|
||||
limit = RELAY_GROUP_JOINED_PREVIEW_LIMIT,
|
||||
since = if (alreadyBackfilled) since?.get(relay)?.time else null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return rosterFilters + contentFilters
|
||||
}
|
||||
|
||||
override fun id(key: RelayGroupMyJoinedGroupsQueryState) = key.account
|
||||
|
||||
private val toggleJobs = mutableMapOf<Account, Job>()
|
||||
|
||||
override fun newSub(key: RelayGroupMyJoinedGroupsQueryState): Subscription {
|
||||
toggleJobs.remove(key.account)?.cancel()
|
||||
toggleJobs[key.account] =
|
||||
key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.NIP29) { invalidateFilters() }
|
||||
return super.newSub(key)
|
||||
}
|
||||
|
||||
override fun endSub(
|
||||
key: Account,
|
||||
subId: String,
|
||||
) {
|
||||
super.endSub(key, subId)
|
||||
toggleJobs.remove(key)?.cancel()
|
||||
}
|
||||
}
|
||||
-56
@@ -1,56 +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.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* Mount on any screen that lists the user's joined groups (the Messages tab's
|
||||
* inline/grouped views, the Relay Groups home) to keep their rosters live.
|
||||
*
|
||||
* The query state is keyed on the account (stable), so the assembler wouldn't
|
||||
* re-run its filter derivation on its own when the joined-group set changes.
|
||||
* We watch [liveRelayGroupList] and invalidate the assembler on every change, so
|
||||
* a join/leave while this screen stays foregrounded immediately re-subscribes to
|
||||
* the new group's roster (critical for confirming admission to a closed group).
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupMyJoinedGroupsSubscription(
|
||||
dataSource: RelayGroupMyJoinedGroupsFilterAssembler,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val state =
|
||||
remember(accountViewModel.account) {
|
||||
RelayGroupMyJoinedGroupsQueryState(accountViewModel.account)
|
||||
}
|
||||
|
||||
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
|
||||
.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(joined) { dataSource.invalidateFilters() }
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
Reference in New Issue
Block a user