From 399bef6bf739fbfaba933b0dbc60b38477187d4c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 17:13:56 +0000 Subject: [PATCH] test: cover NIP-29 group-chat filter shapes and can't-miss history paging Extract the pure REQ-filter construction out of the NIP-29 group-chat assemblers into RelayGroupFilterBuilders so the exact filter each screen puts on the wire can be unit-tested without an Account or relay client, and point every assembler at the shared builders (dropping the duplicated per-file kind lists). Add two test suites from the branch's test plan: - RelayGroupFilterBuildersTest (Tier B): pins the kinds, #d/#h scope, per-host-relay batching, since/until/limit and all-authors shape of the state, joined chat-tail, open chat-tail, history-pager, threads and card-warmup joined-skip filters. - RelayGroupHistoryPagingRelayTest (Tier C3/E1): the can't-miss-messages property against the in-process geode relay -- a backward #h + kind-9 walk delivers every group message exactly once and stops on an empty page, and an #h-scoped walk isolates one group from another on the same relay even when their createdAt ranges fully overlap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU --- .../2026-07-18-nip29-group-chat-test-plan.md | 6 + .../RelayGroupCardWarmupFilterAssembler.kt | 14 +- .../datasource/RelayGroupFilterBuilders.kt | 185 ++++++++++++++++++ ...RelayGroupJoinedChatTailFilterAssembler.kt | 25 +-- .../RelayGroupJoinedStateFilterAssembler.kt | 34 +--- ...elayGroupOpenChatHistoryFilterAssembler.kt | 20 +- .../RelayGroupOpenChatTailFilterAssembler.kt | 22 +-- .../RelayGroupOpenThreadsFilterAssembler.kt | 15 +- .../RelayGroupFilterBuildersTest.kt | 160 +++++++++++++++ .../RelayGroupHistoryPagingRelayTest.kt | 153 +++++++++++++++ 10 files changed, 512 insertions(+), 122 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayGroupHistoryPagingRelayTest.kt diff --git a/amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md b/amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md index be19a9c8fc..ad1278c7c9 100644 --- a/amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md +++ b/amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md @@ -4,6 +4,12 @@ state-vs-content refactor (see `2026-07-18-nip29-group-chat-subscriptions.md`). **Question this answers:** *does the correct data load on every screen, and can we ever miss a message?* +**Implemented on this branch so far:** +- **Tier B** (filter shapes, assemblers 1–6 + card-warmup joined-skip): `amethyst/src/test/.../relayGroup/datasource/RelayGroupFilterBuildersTest.kt`, testing the pure `RelayGroupFilterBuilders.kt` the assemblers now delegate to. +- **Tier C3 / E1** (can't-miss backward `#h` walk + same-relay group isolation, against the in-process `geode` relay): `quartz/src/jvmAndroidTest/.../paging/RelayGroupHistoryPagingRelayTest.kt`. + +Still to do: the remaining Tier B rows (7–10, reconnect stability), the wider Tier C `amy` integration set, Tier D device runs, and Tier E2 real-relay conformance. + ## What is / isn't verifiable headless | Layer | Harness | Covers | diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupCardWarmupFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupCardWarmupFilterAssembler.kt index 17870d3bef..b03f81acc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupCardWarmupFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupCardWarmupFilterAssembler.kt @@ -29,13 +29,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dataso import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag -import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent -import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent -import com.vitorpamplona.quartz.nipC7Chats.ChatEvent /** One on-screen group card's request to warm a single group. */ class RelayGroupCardWarmupQueryState( @@ -47,10 +42,6 @@ class RelayGroupCardWarmupQueryState( val contentLimit: Int = RELAY_GROUP_WARMUP_LIMIT, ) -/** Newest content kinds we prefetch so opening the card lands on populated screens. */ -private val RELAY_GROUP_WARMUP_CONTENT_KINDS = - listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND) - /** * Default number of recent events to pull ahead of a tap — enough to fill the first screen AND drive * the discovery card's "50+ messages" activity signal (a chat that returns the full page reads as @@ -95,8 +86,7 @@ class RelayGroupCardWarmupSubAssembler( // groups shown as cards that those don't cover — above all NON-joined groups (discovery, a relay's // channel list, member/metadata/parent screens). So skip a group we've already joined to avoid // re-fetching what's live everywhere. See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md. - val joined = key.account.relayGroupList.liveRelayGroupList.value - if (joined.any { it.groupId == groupId.id && RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == groupId.relayUrl }) { + if (isRelayGroupJoined(key.account.relayGroupList.liveRelayGroupList.value, groupId)) { return emptyList() } val metadata = if (key.contentOnly) emptyList() else filterRelayGroupState(key.channel, since) @@ -105,7 +95,7 @@ class RelayGroupCardWarmupSubAssembler( relay = groupId.relayUrl, filter = Filter( - kinds = RELAY_GROUP_WARMUP_CONTENT_KINDS, + kinds = RELAY_GROUP_CARD_WARMUP_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), limit = key.contentLimit, since = since?.get(groupId.relayUrl)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt new file mode 100644 index 0000000000..bec6186c7a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt @@ -0,0 +1,185 @@ +/* + * 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.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.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent + +/* + * Pure REQ-filter builders for the NIP-29 group-chat data sources. Kept separate from the assemblers so + * the exact filter each screen puts on the wire (kinds, #d/#h scope, per-relay batching, since/until/limit, + * authors) can be unit-tested without standing up an Account or relay client. + * + * See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md and the companion test plan. + */ + +/** Relay-signed group *state*: metadata + admins + members + roles + pins. Small replaceable events. */ +val RELAY_GROUP_STATE_KINDS = + listOf( + GroupMetadataEvent.KIND, + GroupAdminsEvent.KIND, + GroupMembersEvent.KIND, + SupportedRolesEvent.KIND, + GroupPinnedEvent.KIND, + ) + +/** Timeline kinds shown in a group's chat — chat messages and polls. */ +val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND) + +/** Forum-thread kinds shown in a group's Threads tab. */ +val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND) + +/** Content kinds a card warms ahead of a tap (chat + polls + threads + comments). */ +val RELAY_GROUP_CARD_WARMUP_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND) + +/** `d`-tag key of the relay-signed state events (39xxx are addressable by the group id). */ +private const val D_TAG = "d" + +private fun byHostRelay(joined: Collection): Map> { + val out = LinkedHashMap>() + joined.forEach { tag -> + val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@forEach + out.getOrPut(relay) { mutableListOf() }.add(tag.groupId) + } + return out +} + +/** + * State (39000-39005) for every joined group, **one `#d` filter per host relay** carrying that relay's + * group ids. `since` is per-relay (replaceable events; a reconnect just re-confirms). + */ +fun buildRelayGroupStateFilters( + joined: Collection, + sinceForRelay: (NormalizedRelayUrl) -> Long?, +): List = + byHostRelay(joined).map { (relay, ids) -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RELAY_GROUP_STATE_KINDS, + tags = mapOf(D_TAG to ids.distinct()), + since = sinceForRelay(relay), + ), + ) + } + +/** + * Recent chat of every joined group, **one `#h` filter per host relay** carrying that relay's group ids, + * bounded by a shared time floor ([sinceEpoch]) and **no per-group `limit`** — this is what lets the whole + * relay's groups batch into a single REQ and makes it reconnect-safe. + */ +fun buildRelayGroupJoinedChatTailFilters( + joined: Collection, + sinceEpoch: Long, +): List = + byHostRelay(joined).map { (relay, ids) -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RELAY_GROUP_TIMELINE_KINDS, + tags = mapOf(GroupIdTag.TAG_NAME to ids.distinct()), + since = sinceEpoch, + ), + ) + } + +/** The recent-chat live tail for a single open group, `#h`-scoped on its host relay. */ +fun buildRelayGroupOpenChatTailFilter( + groupId: GroupId, + sinceEpoch: Long, +): RelayBasedFilter = + RelayBasedFilter( + relay = groupId.relayUrl, + filter = + Filter( + kinds = RELAY_GROUP_TIMELINE_KINDS, + tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), + since = sinceEpoch, + ), + ) + +/** + * Backward-history page(s) for a single open group: one `#h` filter per **armed** relay at its own + * `until`, capped by [limit], **all authors** (so it also re-materializes the user's own history). A + * relay with no requested `until` contributes nothing (it is parked). + */ +fun buildRelayGroupHistoryFilters( + groupId: GroupId, + armedRelays: Collection, + untilForRelay: (NormalizedRelayUrl) -> Long?, + limit: Int, +): List = + armedRelays.mapNotNull { relay -> + val until = untilForRelay(relay) ?: return@mapNotNull null + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RELAY_GROUP_TIMELINE_KINDS, + tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), + until = until, + limit = limit, + ), + ) + } + +/** The Threads-tab feed for a single open group: kind-11/1111 `#h`-scoped on the host relay. */ +fun buildRelayGroupThreadsFilter( + groupId: GroupId, + sinceEpoch: Long?, +): RelayBasedFilter = + RelayBasedFilter( + relay = groupId.relayUrl, + filter = + Filter( + kinds = RELAY_GROUP_THREAD_KINDS, + tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), + since = sinceEpoch, + ), + ) + +/** + * Whether [groupId] is in the user's joined set — a joined group is kept warm app-wide by the always-on + * state + chat-tail subs, so the on-screen [RelayGroupCardWarmupFilterAssembler] must skip it. + */ +fun isRelayGroupJoined( + joined: Collection, + groupId: GroupId, +): Boolean = + joined.any { + it.groupId == groupId.id && RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == groupId.relayUrl + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt index deb98e0e41..8a6e02f52e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt @@ -32,19 +32,11 @@ 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.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag -import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent -import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow -/** Timeline kinds shown in a group's chat — chat messages and polls. */ -private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND) - /** One screen's request to keep the user's joined groups' recent chat live. */ class RelayGroupJoinedChatTailQueryState( val account: Account, @@ -102,22 +94,7 @@ class RelayGroupJoinedChatTailSubAssembler( // One #h filter per host relay carrying every joined group id on it; bounded by the shared // recent-tail floor, so no per-group limit and no per-group re-subscribe on join. - val idsByRelay = joined.groupBy({ it.relayUrl }, { it.groupId }) - val sinceTime = DmHistoryTuning.recentBoundary() - - val filters = - idsByRelay.mapNotNull { (relayUrl, groupIds) -> - val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = RELAY_GROUP_TIMELINE_KINDS, - tags = mapOf(GroupIdTag.TAG_NAME to groupIds.distinct()), - since = sinceTime, - ), - ) - } + val filters = buildRelayGroupJoinedChatTailFilters(joined, DmHistoryTuning.recentBoundary()) windowLoad.setExpectedRelays(filters.mapTo(mutableSetOf()) { it.relay }) return filters } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt index a732130a4a..3f55679e1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt @@ -29,28 +29,8 @@ 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.metadata.GroupAdminsEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent import kotlinx.coroutines.Job -/** - * Relay-signed group *state*: metadata (39000) + admins (39001) + members (39002) + supported roles - * (39003) + pins (39005). Small replaceable/addressable events — one per kind per group. - */ -private val RELAY_GROUP_STATE_KINDS = - listOf( - GroupMetadataEvent.KIND, - GroupAdminsEvent.KIND, - GroupMembersEvent.KIND, - SupportedRolesEvent.KIND, - GroupPinnedEvent.KIND, - ) - /** One request to keep the relay-signed state of the user's joined groups live. */ class RelayGroupJoinedStateQueryState( val account: Account, @@ -93,19 +73,7 @@ class RelayGroupJoinedStateSubAssembler( val joined = key.account.relayGroupList.liveRelayGroupList.value if (joined.isEmpty()) return null - val idsByRelay = joined.groupBy({ it.relayUrl }, { it.groupId }) - return idsByRelay.mapNotNull { (relayUrl, groupIds) -> - val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = RELAY_GROUP_STATE_KINDS, - tags = mapOf("d" to groupIds.distinct()), - since = since?.get(relay)?.time, - ), - ) - } + return buildRelayGroupStateFilters(joined) { since?.get(it)?.time } } override fun id(key: RelayGroupJoinedStateQueryState) = key.account diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt index db325734fd..3cad4be707 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt @@ -35,15 +35,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscriptio 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.tags.GroupIdTag -import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent -import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.StateFlow -/** Timeline kinds paged in a NIP-29 group's chat — chat messages and polls. */ -private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND) - /** One open NIP-29 group whose older history the chat screen wants paged in. */ class RelayGroupOpenChatHistoryQueryState( val account: Account, @@ -106,19 +100,7 @@ class RelayGroupOpenChatHistorySubAssembler( // relay keeps no filter here, so re-assembly (a marker advancing) doesn't re-REQ a settled window. val armed = pager.armedRelays(relays) if (armed.isEmpty()) return emptyList() - return armed.mapNotNull { relay -> - val until = pager.requestedUntilFor(relay) ?: return@mapNotNull null - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = RELAY_GROUP_TIMELINE_KINDS, - tags = mapOf(GroupIdTag.TAG_NAME to listOf(key.groupId.id)), - until = until, - limit = pager.pageLimit, - ), - ) - } + return buildRelayGroupHistoryFilters(key.groupId, armed, { pager.requestedUntilFor(it) }, pager.pageLimit) } /** Steps a single [relay] to its next, older page for the open group. Driven by its on-screen marker. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt index 1b14d78872..63c08e0b5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt @@ -30,18 +30,11 @@ 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.NormalizedRelayUrl import com.vitorpamplona.quartz.nip29RelayGroups.GroupId -import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag -import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent -import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.StateFlow -/** Timeline kinds shown in a group's chat — chat messages and polls. */ -private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND) - /** One open NIP-29 group whose recent chat the screen wants live. */ class RelayGroupOpenChatTailQueryState( val account: Account, @@ -82,19 +75,8 @@ class RelayGroupOpenChatTailSubAssembler( key: RelayGroupOpenChatTailQueryState, since: SincePerRelayMap?, ): List { - val relay = key.groupId.relayUrl - windowLoad.setExpectedRelays(setOf(relay)) - return listOf( - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = RELAY_GROUP_TIMELINE_KINDS, - tags = mapOf(GroupIdTag.TAG_NAME to listOf(key.groupId.id)), - since = DmHistoryTuning.recentBoundary(), - ), - ), - ) + windowLoad.setExpectedRelays(setOf(key.groupId.relayUrl)) + return listOf(buildRelayGroupOpenChatTailFilter(key.groupId, DmHistoryTuning.recentBoundary())) } override fun id(key: RelayGroupOpenChatTailQueryState) = key.groupId diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsFilterAssembler.kt index 9b396f1a6a..9475f67f2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsFilterAssembler.kt @@ -26,10 +26,7 @@ import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEo 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.filters.Filter -import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId -import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent /** One threads-screen's request for a single group's kind-11 threads. */ class RelayGroupOpenThreadsQueryState( @@ -67,17 +64,7 @@ class RelayGroupOpenThreadsSubAssembler( since: SincePerRelayMap?, ): List { val groupId = key.channel.groupId - return listOf( - RelayBasedFilter( - relay = groupId.relayUrl, - filter = - Filter( - kinds = listOf(ThreadEvent.KIND, CommentEvent.KIND), - tags = mapOf("h" to listOf(groupId.id)), - since = since?.get(groupId.relayUrl)?.time, - ), - ), - ) + return listOf(buildRelayGroupThreadsFilter(groupId, since?.get(groupId.relayUrl)?.time)) } override fun id(key: RelayGroupOpenThreadsQueryState) = key.channel.groupId diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt new file mode 100644 index 0000000000..380b53a157 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt @@ -0,0 +1,160 @@ +/* + * 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.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the exact REQ each NIP-29 data source puts on the wire (kinds, `#d`/`#h` scope, per-relay + * batching, `since`/`until`/`limit`, authors) — the "does the correct query load per screen" half of + * amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md (Tier B), without an Account/relay client. + */ +class RelayGroupFilterBuildersTest { + private val relayAUrl = "wss://relay-a.example/" + private val relayBUrl = "wss://relay-b.example/" + private val relayA = RelayUrlNormalizer.normalizeOrNull(relayAUrl)!! + private val relayB = RelayUrlNormalizer.normalizeOrNull(relayBUrl)!! + + // Joined set: two groups on relay A, one on relay B. + private val joined = + setOf( + GroupTag("g1", relayAUrl), + GroupTag("g2", relayAUrl), + GroupTag("g3", relayBUrl), + ) + private val g1OnA = GroupId("g1", relayA) + + private val timelineKinds = listOf(ChatEvent.KIND, PollEvent.KIND) + private val threadKinds = listOf(ThreadEvent.KIND, CommentEvent.KIND) + + // --- State (always-on): one #d filter per host relay, batching that relay's group ids --- + + @Test + fun `state batches one d-filter per host relay`() { + val filters = buildRelayGroupStateFilters(joined) { null } + assertEquals(2, filters.size) + + val a = filters.single { it.relay == relayA } + assertEquals(RELAY_GROUP_STATE_KINDS, a.filter.kinds) + assertEquals(setOf("g1", "g2"), a.filter.tags!!["d"]!!.toSet()) + assertNull("state is #d-scoped, never #h", a.filter.tags!!["h"]) + + val b = filters.single { it.relay == relayB } + assertEquals(listOf("g3"), b.filter.tags!!["d"]) + } + + @Test + fun `state applies the per-relay since`() { + val filters = buildRelayGroupStateFilters(joined) { relay -> if (relay == relayA) 111L else null } + assertEquals(111L, filters.single { it.relay == relayA }.filter.since) + assertNull(filters.single { it.relay == relayB }.filter.since) + } + + // --- Joined chat tail (always-on): batched #h per relay, time floor, NO per-group limit --- + + @Test + fun `joined chat tail batches one h-filter per relay with no count limit`() { + val filters = buildRelayGroupJoinedChatTailFilters(joined, 999L) + assertEquals(2, filters.size) + + val a = filters.single { it.relay == relayA } + assertEquals(timelineKinds, a.filter.kinds) + assertEquals(setOf("g1", "g2"), a.filter.tags!!["h"]!!.toSet()) + assertEquals(999L, a.filter.since) + assertNull("a time-floored tail must NOT cap by count (that is what lets it batch)", a.filter.limit) + assertNull(a.filter.until) + } + + // --- Open chat tail: a single group's recent #h on the host relay --- + + @Test + fun `open chat tail is a single host h-filter with since only`() { + val f = buildRelayGroupOpenChatTailFilter(g1OnA, 5L) + assertEquals(relayA, f.relay) + assertEquals(timelineKinds, f.filter.kinds) + assertEquals(listOf("g1"), f.filter.tags!!["h"]) + assertEquals(5L, f.filter.since) + assertNull(f.filter.until) + assertNull(f.filter.limit) + } + + // --- Open chat history (backward pager): only armed relays, each at its own until, all authors --- + + @Test + fun `history emits only armed relays at their until, all authors`() { + val untilByRelay = mapOf(relayA to 200L) // relayB not armed → no cursor + val filters = buildRelayGroupHistoryFilters(g1OnA, listOf(relayA, relayB), { untilByRelay[it] }, 50) + + val f = filters.single() + assertEquals(relayA, f.relay) + assertEquals(200L, f.filter.until) + assertEquals(50, f.filter.limit) + assertNull("history must be all-authors so it re-materializes my own messages too", f.filter.authors) + assertNull("backward paging is until-anchored, not since", f.filter.since) + assertEquals(listOf("g1"), f.filter.tags!!["h"]) + } + + @Test + fun `history with nothing armed builds no filters`() { + assertTrue(buildRelayGroupHistoryFilters(g1OnA, emptyList(), { 1L }, 50).isEmpty()) + } + + // --- Threads tab: kind 11/1111 #h on the host relay --- + + @Test + fun `threads filter is host-scoped thread kinds`() { + val f = buildRelayGroupThreadsFilter(g1OnA, 7L) + assertEquals(relayA, f.relay) + assertEquals(threadKinds, f.filter.kinds) + assertEquals(listOf("g1"), f.filter.tags!!["h"]) + assertEquals(7L, f.filter.since) + } + + // --- Card warmup joined-skip: joined groups are covered always-on, so warmup must skip them --- + + @Test + fun `joined check keys on both group id and host relay`() { + assertTrue(isRelayGroupJoined(joined, GroupId("g1", relayA))) + // Same group id but a DIFFERENT host relay is a different group — must not count as joined. + assertFalse(isRelayGroupJoined(joined, GroupId("g1", relayB))) + assertFalse(isRelayGroupJoined(joined, GroupId("gX", relayA))) + assertFalse(isRelayGroupJoined(emptySet(), GroupId("g1", relayA))) + } + + // --- Empty joined set --- + + @Test + fun `empty joined set produces no filters`() { + assertTrue(buildRelayGroupStateFilters(emptySet()) { null }.isEmpty()) + assertTrue(buildRelayGroupJoinedChatTailFilters(emptySet(), 1L).isEmpty()) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayGroupHistoryPagingRelayTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayGroupHistoryPagingRelayTest.kt new file mode 100644 index 0000000000..83e1ad4e5c --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayGroupHistoryPagingRelayTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.paging + +import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest +import com.vitorpamplona.geode.testing.collectUntilEose +import com.vitorpamplona.geode.testing.preload +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The **can't-miss-messages** property of the NIP-29 group-history pager, proven against the in-process + * relay rather than a mocked filter. The `RelayGroupOpenChatHistorySubAssembler` walks a group's chat + * backward with an `#h`-scoped `until`+`limit` REQ; this pins the two relay-side guarantees that whole + * design rests on: + * + * 1. A backward `#h`+kind-9 walk over one group delivers **every** message **exactly once** and stops + * cleanly on an empty page — no gap, no re-download (the same contract as + * [UntilLimitPagingRelayTest], but with the group's `h`-tag on the wire). + * 2. An `#h`-scoped walk **isolates one group from another on the same relay**, even when their + * `createdAt` ranges fully overlap — so paging one group can never surface, or be blocked by, + * another group's messages. This is the guarantee that a serving relay hosting many groups can't + * leak or hide messages across the `h`-tag boundary. + */ +class RelayGroupHistoryPagingRelayTest : RelayClientTest() { + /** One group's kind-9 chat: distinct ids, monotonic createdAt from 1, all carrying `#h = groupId`. */ + private fun groupChat( + idBase: Int, + count: Int, + groupId: String, + ): List = + List(count) { i -> + SyntheticEvents.fakeEvent( + idSeed = idBase + i, + kind = ChatEvent.KIND, + createdAt = (i + 1).toLong(), + tags = arrayOf(arrayOf(GroupIdTag.TAG_NAME, groupId)), + ) + } + + private fun hFilter( + groupId: String, + until: Long?, + ) = Filter( + kinds = listOf(ChatEvent.KIND), + tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId)), + until = until, + limit = LIMIT, + ) + + @Test + fun backwardHTagWalkCoversEveryGroupMessageOnceAndStopsOnEmptyPage() = + runBlocking { + defaultRelay.preload(groupChat(idBase = 1, count = TOTAL, groupId = "g1")) + // A second group on the same relay must never leak into g1's walk. + defaultRelay.preload(groupChat(idBase = 1_000_000, count = 77, groupId = "g2")) + + val seenIds = mutableSetOf() + var totalReceived = 0 + var pages = 0 + var until: Long? = null + + while (pages < SAFETY_CAP) { + val (events, eose) = client.collectUntilEose(defaultRelayUrl, hFilter("g1", until)) + assertTrue(eose, "every page must end with EOSE") + + if (events.isEmpty()) break // gap-proof stop: empty page = nothing older + + pages++ + assertTrue(events.size <= LIMIT, "page must respect the limit") + assertTrue(events.all { it.isTaggedGroup("g1") }, "an #h=g1 REQ must return only g1 messages") + until?.let { cursor -> assertTrue(events.all { it.createdAt <= cursor }, "page must be older than the cursor") } + + events.forEach { e -> + seenIds.add(e.id) + totalReceived++ + } + until = events.minOf { it.createdAt } - 1 + } + + assertEquals(TOTAL, totalReceived, "no message delivered twice across pages") + assertEquals(TOTAL, seenIds.size, "every group message fetched exactly once") + // 250 / 100 → 100 + 100 + 50, then an empty page stops the walk. + assertEquals(3, pages) + } + + @Test + fun hTagScopedWalkIsolatesGroupsWithOverlappingTimeRangesOnTheSameRelay() = + runBlocking { + // Two groups on ONE relay, createdAt ranges deliberately OVERLAPPING (both start at 1) so the + // only thing that can separate them is the #h tag, never time. + defaultRelay.preload(groupChat(idBase = 1, count = 40, groupId = "gA")) + defaultRelay.preload(groupChat(idBase = 1_000, count = 60, groupId = "gB")) + + val a = walkGroup("gA") + val b = walkGroup("gB") + + assertEquals(40, a.size, "the #h=gA walk sees exactly gA's messages") + assertEquals(60, b.size, "the #h=gB walk sees exactly gB's messages") + assertTrue(a.all { it.isTaggedGroup("gA") }, "gA walk must not surface any gB message") + assertTrue(b.all { it.isTaggedGroup("gB") }, "gB walk must not surface any gA message") + assertTrue(a.map { it.id }.intersect(b.map { it.id }.toSet()).isEmpty(), "no message id belongs to both groups") + } + + /** Full backward `#h`-scoped drain for one group; returns the distinct events it delivered. */ + private suspend fun walkGroup(groupId: String): List { + val out = mutableMapOf() + var until: Long? = null + var pages = 0 + while (pages < SAFETY_CAP) { + val (events, eose) = client.collectUntilEose(defaultRelayUrl, hFilter(groupId, until)) + assertTrue(eose) + if (events.isEmpty()) break + pages++ + events.forEach { out[it.id] = it } + until = events.minOf { it.createdAt } - 1 + } + return out.values.toList() + } + + private fun Event.isTaggedGroup(groupId: String) = tags.any { it.size >= 2 && it[0] == GroupIdTag.TAG_NAME && it[1] == groupId } + + companion object { + private const val TOTAL = 250 + private const val LIMIT = 100 + private const val SAFETY_CAP = 10 + } +}