mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
This commit is contained in:
@@ -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 |
|
||||
|
||||
+2
-12
@@ -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,
|
||||
|
||||
+185
@@ -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<GroupTag>): Map<NormalizedRelayUrl, List<String>> {
|
||||
val out = LinkedHashMap<NormalizedRelayUrl, MutableList<String>>()
|
||||
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<GroupTag>,
|
||||
sinceForRelay: (NormalizedRelayUrl) -> Long?,
|
||||
): List<RelayBasedFilter> =
|
||||
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<GroupTag>,
|
||||
sinceEpoch: Long,
|
||||
): List<RelayBasedFilter> =
|
||||
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<NormalizedRelayUrl>,
|
||||
untilForRelay: (NormalizedRelayUrl) -> Long?,
|
||||
limit: Int,
|
||||
): List<RelayBasedFilter> =
|
||||
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<GroupTag>,
|
||||
groupId: GroupId,
|
||||
): Boolean =
|
||||
joined.any {
|
||||
it.groupId == groupId.id && RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == groupId.relayUrl
|
||||
}
|
||||
+1
-24
@@ -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
|
||||
}
|
||||
|
||||
+1
-33
@@ -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
|
||||
|
||||
+1
-19
@@ -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. */
|
||||
|
||||
+2
-20
@@ -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<RelayBasedFilter> {
|
||||
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
|
||||
|
||||
+1
-14
@@ -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<RelayBasedFilter> {
|
||||
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
|
||||
|
||||
+160
@@ -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())
|
||||
}
|
||||
}
|
||||
+153
@@ -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<Event> =
|
||||
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<String>()
|
||||
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<Event> {
|
||||
val out = mutableMapOf<String, Event>()
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user