diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md new file mode 100644 index 0000000000..b93c13e2ec --- /dev/null +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -0,0 +1,332 @@ +# DM loading: live tail + per-relay history paging + +> **Status:** authoritative as of 2026-06-05. The "Current architecture" +> section below describes the code as it actually stands. The original +> time-slice design and the round-model history are kept at the bottom under +> **Design evolution (historical)** — they are superseded and no longer match +> the code; don't trust them for how it works today. + +## Problem + +The DM loaders used a single subscription whose `since` floor grew as the user +scrolled (`loadMore`: 7d → 14d → 28d → …). The filter carried **only `since`, +no `until`**, so every widen re-requested the whole window and the relay +re-streamed the entire history from the new floor. Traces showed this directly: + +``` +[giftwrap] load summary: 589 event(s) (14d) +[giftwrap] load summary: 1486 event(s) (28d) +[giftwrap] load summary: 2609 event(s) (56d) +``` + +Each step re-downloaded everything it already had plus the new slice — the +"getting all events over and over again" the owner reported. It also cascaded: +a few pixels of scroll walked the window to the 10-year backstop, because +widening pulls older *messages* but the rooms list is keyed by *conversation*, +so a handful of busy correspondents flood thousands of events without adding a +single new row, and the "scrolled near the oldest room" trigger never clears. + +--- + +## Current architecture + +### Two layers per protocol + +Each DM protocol — **NIP-17** gift wraps (kind 1059) and **NIP-04** legacy DMs +(kind 4) — is split into two independent responsibilities: + +1. **Live tail** — a fixed ~1-week floor with **no `until`**, open to the + future. Never widens. New messages always arrive here. Backed by the + **round model** (`WindowLoadTracker`): one REQ fanned to every relay, "done" + when all settle, drives the boot spinner. +2. **History** — everything *older* than the week floor, paged **backward by + `until`+`limit`, per relay, on demand**. Backed by the **per-relay model** + (`RelayLoadingCursors` + `PerRelayLoadTracker`), driven by on-screen markers. + +The two are disjoint in time, so re-issuing a history page never re-streams the +live tail, and consecutive history pages never re-stream each other. + +| Surface | Live-tail manager (round) | History manager (per-relay) | +|---|---|---| +| Account gift wraps (NIP-17) | `AccountGiftWrapsEoseManager` | `AccountGiftWrapsHistoryEoseManager` | +| Conversation NIP-04 | `ChatroomNip04SubAssembler` | `ChatroomNip04HistorySubAssembler` | +| Rooms-list NIP-04 | `ChatroomListNip04SubAssembler` | `ChatroomListNip04HistorySubAssembler` | + +Accessed from the UI via `accountViewModel.dataSources()` as +`.account.giftWrapsHistory`, `.chatroom.nip04History`, +`.chatroomList.nip04History`. + +### The history paging primitive: `RelayLoadingCursors` + +The time-window model can't tell "this relay is empty" from "this is a gap" — a +`since`/`until` slice that returns nothing might just be a quiet stretch above +older messages. Paging by `until`+`limit` removes that ambiguity: a relay +returns up to `limit` (**10000**) of its newest events older than the cursor, +**skipping gaps**, so an **empty page + EOSE is a gap-proof "nothing older"**. + +Per relay, two cursors are kept deliberately decoupled: + +- `requestedUntil` — the `until` the REQ carries. Moves **only** in `advance()`. + Leaving it untouched on EOSE is what makes paging demand-driven: a relay that + finished a page just **parks** at the same filter (no re-REQ) until advanced. +- `reachedUntil` — the oldest `created_at` actually delivered. Moves on EOSE. + The in-stream markers sit here; the next page starts at `reachedUntil − 1`. + +Stop signals: an empty page marks the relay **`done`**. A relay returning fewer +than `limit` is treated as its own cap, **not** exhaustion. A misbehaving relay +that returns events but none older than already reached (echoing its newest +events) is also treated as the bottom, so its marker can't re-request the same +window forever. Tested in `RelayLoadingCursorsTest.kt`. + +### The two completion models (and where each lives) + +**Round model — `WindowLoadTracker` (live tail only).** One REQ is fanned to all +relays; the window is "done" only when **every** expected relay reaches a +terminal signal (`settled ⊇ expected`), with backstops for stragglers (idle / +silence / connect-grace / absolute cap). `loading` starts **`true`**. This is a +*barrier*: nobody moves on until the cohort answers. It is the right shape for +the one-shot fixed-window backfill the live tail does. + +> Note: the silence + connect-grace backstops are gated behind `tracksReqSends`, +> and **none of the three live-tail managers pass `tracksReqSends = true`**, so +> in current use only the settle / idle / cap paths ever fire. The REQ-aware +> machinery is dormant in production — see "Things to scrutinize". + +**Per-relay model — `RelayLoadingCursors` + `PerRelayLoadTracker` (all history).** +Each relay advances to its next page the instant *it* EOSEs, independent of the +others; the subscription layer diffs per relay, so re-issuing only re-REQs the +relay whose cursor moved. `loading` starts **`false`** (a `true` start would +wedge the scroll loader's `!loading` gate on first open). Fast relays race to +the bottom in back-to-back pages; slow / auth-walled relays catch up at their +own pace and **none are abandoned** — a stalled relay keeps its subscription +open and resumes when re-advanced. This removes the round model's +slowest-relay coupling, which matters most on the conversation screen where the +fan-out includes correspondents' (often auth-walled, slow) relays. + +`exhausted` (per history manager) flips true when **every relay is `done` OR +`stalled`** — "nothing more reachable right now". A merely *parked* relay (more +to load, just not advancing) keeps it false. + +> All three history managers (`AccountGiftWrapsHistoryEoseManager`, +> `ChatroomNip04HistorySubAssembler`, `ChatroomListNip04HistorySubAssembler`) +> were structurally the same per-relay loader, so that bookkeeping is now a +> single reusable engine — **`BackwardRelayPager`** (keyless, single-active). It +> does **not** hold the cursors: the per-relay `RelayLoadingCursors` live on the +> scope's own domain object (a `Chatroom` per conversation, a `ChatroomList` per +> account), so they share the cached messages' lifetime and survive an account +> switch. The orchestrator owns only the transient bits — in-flight + silence +> tracking, the stalled set, and the display flows — and +> `bind(cursors, scope, relaysFor)`s to whichever scope is active. Each manager +> supplies its REQ-filter builder, a `relaysFor` lookup, and the subscription +> wiring (forwards relay callbacks via `onEvent`/`onEose`/`onClosed`/`onCannotConnect`, +> re-issues filters after `advance`/`advanceAll`). The earlier round-model history +> (and the rooms-list "stall-gate") was fully removed — see Design evolution. + +### What drives `advance()`: on-screen markers, off viewport visibility + +History paging is demand-driven by **per-relay window-limit markers** placed in +the message stream, not by a scroll-position trigger: + +- **`RelayReachCursor`** — one per (protocol, relay): its `reachedUntil` depth, + its `RelayReachState` (`REACHING ↓` / `STALLED …` / `DONE ✓`), and the + `advance()` that pulls *that relay's* next page. Built in the feed views from + each history manager's `relayProgress` map (gift wraps + NIP-04 combined; a + protocol drops out of the list once `exhausted`). +- **`RelayReachSentinels`** — the load *driver*, **hoisted above the + `LazyColumn`** (via `ChatFeedView`'s `sentinels` slot). Each non-done limit + gets one stable effect (keyed by `protocol:url`) that watches `listState` and + fires `advance()` when its gap is among the **currently visible rows** AND + either it just scrolled into view OR its `reachedUntil` moved (a page landed — + keep paging while visible). Driving off **viewport visibility** instead of row + composition is deliberate: an earlier version placed the sentinel *inside* the + hosting row, so any feed reorder (a live DM, a slow relay dribbling a page) + tore the effect down and re-fired `advance()` on a static screen — re-arming + stalled relays into a silence-watchdog storm. (commit `0394ec2a`) +- **`RelayReachMarkers` / `RelayReachMarker`** — pure UI (via the + `markersInGap` slot): the "Relay sync: ✓ 8 · ↓ 1" divider at each relay's + reached depth. Can be re-placed on every reorder without triggering paging. +- **`BootstrapHistoryWhenEmpty`** — when the feed is genuinely `Empty` (the live + tail came back empty for a thread/list whose newest message is older than a + week) there are no rows to host markers, so this steps every relay one page at + a time (debounced 1200ms, gated per loader on `!loading && !exhausted`) until + messages appear and the markers take over, or the protocol exhausts. + +### NIP-04 per-relay filter scoping (`Nip04DmRelayRouting`) + +A conversation's NIP-04 filters previously named the whole participant set on +every relay, so a relay belonging to one correspondent was asked about all of +them, and the `from-me` leg (`authors:[me]`) was sent to correspondents' inbox +relays — which auth-walled relays reject outright ("all authors must be +authenticated"), stalling the load. + +`Nip04DmRelayRouting` (in `FilterNip04DMs.kt`) is now two **per-relay key maps** +(`relay → which keys to name there`), built from the outbox model: + +- **to me** (`#p:[me]`) — my inbox carries the whole group; each correspondent's + outbox carries only that correspondent. +- **from me** (`authors:[me]`) — my outbox carries the whole group; each + correspondent's inbox carries only that correspondent. + +So a relay only ever sees the keys that actually own it. The **conversation** +history manager scopes its REQ to the armed relays' key sets this way; the +**rooms-list** and **gift-wrap** history managers query only the account's *own* +relays (home outbox `from-me` + DM inbox `to-me`, via `filterNip04DMsFromMe` / +`filterNip04DMsToMe` and `filterGiftWrapsToPubkey`), which is why their fan-out +stays fast and reachable. + +### Status card terminal states (`DmHistoryLoadingCard`) + +One card per protocol at its oldest-loaded boundary. While paging it shows the +protocol tag, "N relays" being asked, and the reach-back date; it is tappable +into a per-relay popup (`DmHistoryRelayDialog`) listing every relay with +`✓` done / `…` stalled / `↓` reaching and how far back each paged. + +Because `exhausted` conflates `done` and `stalled`, the terminal state is split +on `stalledCount` so it can't overclaim (commit `813110cc`): + +- **caught up** (every relay `done`, `stalledCount == 0`) → "All caught up", + lingers ~2.2s then collapses. +- **incomplete** (≥1 stalled) → "Some relays didn't respond · N unreachable", + error-coloured `…`, **stays put** (no collapse), tappable to see which. + +### Reply placeholder (`LoadingReplyNote`) + +A reply whose target message hasn't been paged in yet isn't *missing*, it's +older than the loaded window (and for gift wraps the rumor id isn't even +queryable — only the outer 1059 wrap is). Instead of the generic `BlankNote` +("post not found"), `LoadingReplyNote` actively walks the relevant protocol's +history backward (kicking `advanceAll` each time a page settles) until the +target decrypts (the surrounding `WatchNoteEvent` crossfades the real message in +and disposes this) or the protocol exhausts. Its terminal state mirrors the +card: "Couldn't find this message" + an honest subtitle ("N relays unreachable · +tap to see which" when stalled, "Searched every relay · tap to see" when +genuinely done), tappable into the same per-relay popup. Wired via +`ChatMessageCompose.RenderReply` → `WatchNoteEvent(onBlank = …)`, with the pager +chosen by the parent event's protocol (`DmReplyProtocol.NIP17` / `NIP04`). + +### Diagnostics + +Everything logs under one tag, **`DMPagination`** (debug builds): +`DmRelayDiagnosticsLogger` folds the per-relay connection timeline (REQ sent, +connect/disconnect, CLOSED/NOTICE/OK-fail) into it; `DmRelayLog` prints the +"relays by source" breakdown (NIP-65 in/out, DM list, private storage, local) +per subscription so an unexpected relay can be traced to the list it leaks in +from; and each assembler logs its milestones (paging start, a relay reaching +the bottom / stalling with the reason, the "window settled" summary of +done-vs-still-trying, each marker fire). + +### Related fix: Tor guard-sample self-heal + +`TorService` gained a `noUsableGuards()` check that, on init, inspects Arti's +persisted `guards.json` and wipes the on-disk state if a non-empty guard set has +**zero** usable guards (all `disabled` / `unlisted`). This recovers the +long-standing "can't connect to Tor → relays permanently unreachable" wedge +(Arti disables guards past a 0.7 indeterminate-failure ratio, never re-enables +them, and can't replenish once the 60-slot sample is full). Orthogonal to +pagination, but it lived here because unreachable relays were part of the same +"DM history stuck / relays never answer" symptom this branch chased. + +--- + +## Component map (vs `origin/main`) + +**Paging primitives (quartz, `nip01Core/relay/client/paging/`, `commonMain`)** — +the pure, protocol-level paging *state*; iOS-clean, reusable by any KMP target. +- `RelayLoadingCursors.kt` — per-relay `until`+`limit` cursor state + pinned floor; + held on the scope's domain object. *(+ `RelayLoadingCursorsTest` in amethyst; the + `until`+`limit` wire contract is covered by `UntilLimitPagingRelayTest` against + the quartz `jvmAndroidTest` geode relay)* +- `RelayPagingProgress.kt` — `(reachedUntil, done, stalled)` per relay. + +**Paging orchestrators (commons, `relayClient/paging/`, `jvmAndroid`)** — the +StateFlow-backed, subscription-loading state holders. Moved out of amethyst **and** +out of quartz (per `commons/ARCHITECTURE.md`: the relay-subscription client + +`StateFlow` state holders live in commons) so desktop / CLI / any feed can reuse +them; `jvmAndroid` (uses `java.util.concurrent` + `@Synchronized`) → Android + +Desktop, not iOS. +- `BackwardRelayPager.kt` — the keyless single-active orchestrator the three + history managers `bind` to. *(+ `BackwardRelayPagerTest` state-machine in commons + `jvmTest`)* +- `PerRelayLoadTracker.kt` — per-relay in-flight tracker + silence watchdog. +- `WindowLoadTracker.kt` — round/barrier completion tracker (live tail). *(+ `WindowLoadTrackerIdleTest` in amethyst)* + +**Diagnostics (amethyst)** — `service/relayClient/eoseManagers/DmRelayLog.kt`, +`service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt` — the `DMPagination` logs. + +**Managers / assemblers** +- `AccountGiftWrapsEoseManager.kt` (live tail) + `AccountGiftWrapsHistoryEoseManager.kt` (new, history). +- `ChatroomNip04SubAssembler.kt` (live tail) + `ChatroomNip04HistorySubAssembler.kt` (new, history). +- `ChatroomListNip04SubAssembler.kt` (live tail) + `ChatroomListNip04HistorySubAssembler.kt` (new, history). +- `FilterNip04DMs.kt` (per-relay `Nip04DmRelayRouting`, live + history builders), `FilterNip04DMsFromMe/ToMe.kt`, `FilterGiftWrapsToPubkey.kt` — `until`/`limit` added. +- `AccountFilterAssembler`, `ChatroomFilterAssembler`, `ChatroomListFilterAssembler` — wire the new managers. + +**Shared UI (commons, `commons/ui/feeds/`)** — extracted from amethyst so Android + +Desktop (and any per-relay feed) render the same widgets; CMP `composeResources` +strings, no app-theme / `java.time` deps. +- `RelayReachMarker.kt` — `RelayReachCursor` + sentinels (the hoisted, visibility-driven + paging driver) + markers (pure UI) + `RelayReachMarker`/`RelayReachState`. +- `DmHistoryLoadingCard.kt` — the boundary status card + per-relay tap dialog + + `historySubtitle`/`incompleteSubtitle`. Takes a `formatReachDate: (epochSeconds) -> String` + so each platform supplies its locale date formatter. + +**Android UI (`amethyst/ui/screen/loggedIn/chats/`)** +- `feed/LoadingReplyNote.kt` — history-walking reply placeholder (uses the shared subtitle helpers/dialog). +- `feed/HistoryDateFormat.kt` — `formatHistoryReachDate`, the Android locale formatter passed into the shared card. +- `feed/ChatFeedView.kt` — `markersInGap` + `sentinels` slots. +- `feed/ChatMessageCompose.kt` — reply `onBlank` wiring. +- `privateDM/ChatroomView.kt`, `rooms/feed/ChatroomListFeedView.kt` — assemble cards/markers/sentinels, `BootstrapHistoryWhenEmpty`. +- `res/values/strings.xml` — `chats_reply_*` (the card's `chats_history_*` now live in commons). + +--- + +## Things to scrutinize (review notes) + +1. **`exhausted` conflates `done` + `stalled`** at the manager level. The cards + now distinguish them via `stalledCount`, but other consumers (the scroll + `!loading` gates, `LoadingReplyNote`'s advance loop) treat stalled as + terminal. Intentional (don't hammer dead relays), but confirm it's desired. +2. **`PerRelayLoadTracker.lastActivityMs` is global, not per-relay** — once the + chatty relays finish, a legitimately-slow relay gets the full 15s silence + window and can be marked stalled mid-delivery of a 10000-event page. +3. **"All caught up" can still be technically-true-but-misleading** when + `stalledCount == 0` yet a chat's messages live on a relay *not in the + account's NIP-17 inbox list* — an outbox-coverage gap the card can't detect. +4. **`WindowLoadTracker`'s REQ-aware backstops are dormant** in production + (no live-tail manager sets `tracksReqSends`). Either the live tail should + adopt them or the round model could be slimmer for its current role. +5. **`PAGE_LIMIT = 10000`** caps per-request volume but a single page can still + be a large payload on a dense relay. + +--- + +## Design evolution (historical — superseded, do not trust for current behavior) + +These sections describe earlier iterations, kept for context. The code has +moved past all of them. + +### v1 — time-slice history (superseded by `RelayLoadingCursors`) + +History was first loaded in bounded `since`+`until` **time slices** +(`TimeWindowPagination`, now deleted): `loadMore` fetched only the new band +`[newFloor, previousFloor]`, with a NIP-17 ±2-day wrapper-timestamp margin on +the slice `since` for gift wraps. This bounded re-downloads but still couldn't +tell an empty relay from a gap (an empty slice might sit above older messages), +so the only stop was a 10-year `maxLookback`, and a wide late slice could pull a +20k-event firehose. Replaced by per-relay `until`+`limit` paging. + +### v2 — round-model history + rooms-list "stall-gate" (both removed) + +History paging once used the **round model** (`WindowLoadTracker`): each +`loadMore` issued one page to all active relays and waited for the slowest to +settle before the next — pacing every relay at the slowest one. The rooms list +additionally had a **stall-gate**: an auto-fill loop that widened only while it +brought in new private rooms, stopping once a widen added none (to avoid the +conversation-keyed cascade). + +Both are gone. All history paging is now per-relay independent +(`PerRelayLoadTracker`), and the rooms list pages to exhaustion off marker +visibility like the conversation (commit `98fb8720` dropped the stall-gate; +`60b8629a` / `9f0ecd54` moved gift-wrap and rooms-list history onto the per-relay +model). `WindowLoadTracker` survives **only** as the live-tail completion +barrier. An earlier revision of this doc ("Update 3") still claimed rooms-list +and gift-wrap history used the round model — that is no longer true. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index bd30452fe1..6313aaab74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator +import com.vitorpamplona.amethyst.service.relayClient.diagnostics.DmRelayDiagnosticsLogger import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState @@ -511,6 +512,9 @@ class AppModules( val relayReqStats = if (isDebug) RelayReqStats(client) else null val logger = if (isDebug) RelaySpeedLogger(client) else null + // Focused timeline for the DM / gift-wrap loading path (tag: DMPagination). + val dmDiagnostics = if (isDebug) DmRelayDiagnosticsLogger(client) else null + // Coordinates all subscriptions for the Nostr Client val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index b316174452..a2bf434c66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2645,20 +2645,67 @@ object LocalCache : ILocalCache, ICacheProvider { } chatroomList.forEach { userHex, room -> + // History floors are pinned per scope on first advance; null means that window never paged + // history, so its cursors hold no position to misalign and nothing needs rewinding. Only the + // bands strictly BELOW a floor are this window's responsibility — a pruned message newer than + // the floor is the always-on live tail's concern, and rewinding history for it would needlessly + // re-page (and, for a busy room straddling the floor, mis-set the boundary). Hence the per-floor + // filter when accumulating below. + val giftWrapFloor = room.giftWrapHistory.floor + val accountNip04Floor = room.nip04History.floor + room.rooms.map { key, chatroom -> val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly() val childrenToBeRemoved = mutableListOf() - toBeRemoved.forEach { - childrenToBeRemoved.addAll(removeIfWrap(it)) - unlinkAndRemove(it) + // Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor. + // Gift wraps page by the OUTER wrap time (from the rumor's host stub); NIP-04 by the event's + // own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor. + val giftWrapPruned = HashMap() + val accountNip04Pruned = HashMap() + val roomNip04Pruned = HashMap() + // chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a + // kind:4 message, so rooms that never paged conversation history pay nothing. + val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null - childrenToBeRemoved.addAll(it.clearChildLinks()) + toBeRemoved.forEach { note -> + when (val ev = note.event) { + is WrappedEvent -> + if (giftWrapFloor != null) { + val outerUntil = ev.host?.createdAt ?: ev.createdAt + if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) } + } + is PrivateDmEvent -> { + val until = ev.createdAt + if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) } + if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) } + } + } + + childrenToBeRemoved.addAll(removeIfWrap(note)) + unlinkAndRemove(note) + + childrenToBeRemoved.addAll(note.clearChildLinks()) } unlinkAndRemove(childrenToBeRemoved) + // Realign the windows so a relay that already paged past (or `done` below) the dropped band + // re-requests it on the next demand-advance instead of skipping the hole. + if (giftWrapPruned.isNotEmpty()) { + room.giftWrapHistory.rewindTo(giftWrapPruned) + Log.d("DMPagination") { "[giftwrap] window rewound after prune: ${giftWrapPruned.size} relay(s), newest pruned wrap @${giftWrapPruned.values.max()}" } + } + if (accountNip04Pruned.isNotEmpty()) { + room.nip04History.rewindTo(accountNip04Pruned) + Log.d("DMPagination") { "[rooms.nip04] window rewound after prune: ${accountNip04Pruned.size} relay(s), newest pruned @${accountNip04Pruned.values.max()}" } + } + if (roomNip04Pruned.isNotEmpty()) { + chatroom.nip04History.rewindTo(roomNip04Pruned) + Log.d("DMPagination") { "[convo.nip04] window rewound after prune of ${key.users.joinToString()}: ${roomNip04Pruned.size} relay(s), newest pruned @${roomNip04Pruned.values.max()}" } + } + if (toBeRemoved.size > 1) { println( "PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt new file mode 100644 index 0000000000..4aefae6ba6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.diagnostics + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.Log + +/** + * Diagnostic connection listener for the DM loading path — both NIP-17 gift wraps + * (kind 1059 / 21059) and NIP-04 legacy DMs (kind 4). + * + * It folds the per-relay timeline — REQ sent, connect/disconnect, NOTICE / CLOSED + * rejection and OK failures — into the single `DMPagination` log tag with an + * elapsed-time prefix, so a slow cold boot can be attributed (connection? relay + * response?) and a silent failure to load (e.g. a relay answering CLOSED + * "auth-required" / "restricted") becomes visible. Per-event lines are omitted to + * keep the trail readable, but the NIP-42 AUTH handshake (challenge in, AUTH out, + * accept/reject) IS logged: an auth-walled relay's whole load hinges on whether + * that round-trip closes and a re-REQ follows, so it has to be attributable here. + * + * The connection listener fires for EVERY relay the app talks to (hundreds, under + * the outbox model). To keep this readable we only log relays that are part of the + * DM path: a relay is "learned" the first time we send it a kind:1059/21059/4 + * REQ, and only those relays' connect/auth/notice lines are emitted thereafter. + */ +class DmRelayDiagnosticsLogger( + val client: INostrClient, +) { + private val startMs = System.currentTimeMillis() + + private fun at() = System.currentTimeMillis() - startMs + + // Subscription ids whose REQ carried a DM kind, so we can attribute their EOSE/CLOSED. + private val dmSubIds = mutableSetOf() + + // Relays we've seen on the DM path, so connect/auth/notice noise from the + // hundreds of unrelated follow/outbox relays is filtered out. + private val dmPathRelays = mutableSetOf() + + // Ids of the AUTH events we've sent to DM relays, so the relay's OK can be tagged "AUTH accepted / + // REJECTED" (the OK that decides whether the post-auth re-REQ will actually be served) instead of + // being lost among ordinary event OKs. + private val authEventIds = mutableSetOf() + + private fun isDmRelay(relay: IRelayClient) = relay.url in dmPathRelays + + private val listener = + object : RelayConnectionListener { + override fun onConnecting(relay: IRelayClient) { + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] connecting ${relay.url.url}" } + } + + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + if (isDmRelay(relay)) { + Log.d(TAG) { "[+${at()}ms] connected ${relay.url.url} (ping ${pingMillis}ms${if (compressed) ", compressed" else ""})" } + } + } + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + if (cmd is AuthCmd) { + // Our reply to a relay's NIP-42 challenge. Remember the id so the relay's OK can be + // tagged as the auth result below. Only for relays already on the DM path. + if (isDmRelay(relay)) { + authEventIds.add(cmd.event.id) + Log.d(TAG) { "[+${at()}ms] AUTH -> ${relay.url.url} success=$success (id ${cmd.event.id.take(8)})" } + } + return + } + if (!isDmReq(cmdStr)) return + dmPathRelays.add(relay.url) + reqSubId(cmdStr)?.let { dmSubIds.add(it) } + Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + is AuthMessage -> + // The relay's NIP-42 challenge. Without it (and the AUTH/OK that follow) an + // auth-walled relay can never serve, so it's the first link to look for. + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] AUTH challenge <- ${relay.url.url} '${msg.challenge.take(40)}'" } + + is NoticeMessage -> + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] NOTICE <- ${relay.url.url} '${msg.message}'" } + + is ClosedMessage -> + if (msg.subId in dmSubIds) { + Log.d(TAG) { "[+${at()}ms] CLOSED <- ${relay.url.url} sub=${msg.subId} reason='${msg.message}'" } + } + + is OkMessage -> + if (msg.eventId in authEventIds) { + // The auth result: "accepted" means a syncFilters re-REQ should now be served; + // "REJECTED" means this relay will keep refusing and never serve our DMs. + Log.d(TAG) { "[+${at()}ms] AUTH ${if (msg.success) "accepted" else "REJECTED"} <- ${relay.url.url} '${msg.message}'" } + } else if (!msg.success && isDmRelay(relay)) { + Log.d(TAG) { "[+${at()}ms] OK(fail) <- ${relay.url.url} '${msg.message}'" } + } + + else -> {} + } + } + + override fun onDisconnected(relay: IRelayClient) { + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] disconnected ${relay.url.url}" } + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] CANNOT CONNECT ${relay.url.url}: $errorMessage" } + } + } + + init { + client.addConnectionListener(listener) + } + + fun destroy() { + client.removeConnectionListener(listener) + } + + companion object { + private const val TAG = "DMPagination" + + // The kinds a DM-path REQ carries: NIP-17 gift wraps (1059 + 21059) and NIP-04 legacy DMs + // (4). Matched exactly against the filter's "kinds" array — never as a substring of the whole + // command, since a pubkey hex or timestamp can incidentally contain "1059" or "4". + private val DM_KINDS = setOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND, PrivateDmEvent.KIND) + + private val KINDS_ARRAY = Regex("\"kinds\":\\[([0-9,\\s]*)]") + + // Extracts the subscription id from a `["REQ","",{...}]` command string. + private val REQ_SUB_ID = Regex("^\\[\"REQ\",\"([^\"]+)\"") + + private fun reqSubId(cmdStr: String) = REQ_SUB_ID.find(cmdStr)?.groupValues?.get(1) + + /** True only when one of the REQ's `kinds` arrays actually contains a DM kind. */ + private fun isDmReq(cmdStr: String): Boolean = + KINDS_ARRAY.findAll(cmdStr).any { match -> + match.groupValues[1] + .split(',') + .any { it.trim().toIntOrNull() in DM_KINDS } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.kt new file mode 100644 index 0000000000..39d1370a16 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log + +/** + * Diagnostic for "which account is bringing which relays into the DM filters". For each DM + * subscription it prints the account whose relays are used and breaks the relay set down by the + * source list each relay comes from (NIP-65 inbox/outbox, the DM-relay-list, the private-storage + * outbox, local relays). Use it to trace an unexpected relay — e.g. a write-only NIP-65 relay that + * only the NIP-04 (home+dm) path queries — back to the list it leaks in from. + */ +object DmRelayLog { + private const val TAG = "DMPagination" + + fun log( + label: String, + account: Account, + ) = Log.d(TAG) { + val pk = account.userProfile().pubkeyHex.take(8) + val inbox = account.nip65RelayList.inboxFlow.value + val outbox = account.nip65RelayList.outboxFlow.value + val dmList = account.dmRelayList.flow.value + val priv = account.privateStorageRelayList.flow.value + val local = account.localRelayList.flow.value + buildString { + append("[$label] account=$pk relays by source:") + appendSource("nip65In", inbox) + appendSource("nip65Out", outbox) + appendSource("dmList", dmList) + appendSource("private", priv) + appendSource("local", local) + } + } + + private fun StringBuilder.appendSource( + name: String, + relays: Collection, + ) { + if (relays.isNotEmpty()) append(" $name=${relays.map { it.url }}") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 6b28e608be..e654e9df0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot. import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -49,10 +50,17 @@ class AccountQueryState( class AccountFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + // Live tail: the recent week of gift wraps, always open at the top for new messages. + val giftWraps = AccountGiftWrapsEoseManager(client, ::allKeys) + + // History: older gift wraps, loaded on demand in bounded one-shot slices. + val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys) + val group = listOf( AccountMetadataEoseManager(client, ::allKeys), - AccountGiftWrapsEoseManager(client, ::allKeys), + giftWraps, + giftWrapsHistory, AccountDraftsEoseManager(client, ::allKeys), AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), MarmotGroupEventsEoseManager(client, ::allKeys), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt index 8f5484a1af..1ddf8e8028 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -20,8 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -29,58 +33,68 @@ 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.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +/** + * Always-on **live tail** for the account's NIP-17 gift wraps (kind 1059). It keeps a fixed + * one-week floor with no upper bound, so the messages list is usable on boot and new incoming + * messages always stream in. It deliberately never widens: pulling older history is the job of + * [AccountGiftWrapsHistoryEoseManager], which fetches the past in bounded, one-shot slices so a + * widen never re-streams what this tail already holds. + */ class AccountGiftWrapsEoseManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { override fun user(key: AccountQueryState) = key.account.userProfile() + // The initial-load tracker drives the boot spinner: it stays true until every DM relay has + // settled (EOSE / CLOSED / cannot-connect) on the one-week tail. + private val windowLoad = WindowLoadTracker("giftwrap.live") + val loadingMore: StateFlow = windowLoad.loading + override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, ): List { - // Only loads DMs if the account is writeable - return if (key.account.isWriteable()) { - val relays = key.account.dmRelays.flow.value - Log.d("MarmotDbg") { - "AccountGiftWrapsEoseManager.updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… " + - "subscribing kind:1059 on ${relays.size} dmRelay(s): ${relays.map { it.url }}" - } - relays.flatMap { relay -> - filterGiftWrapsToPubkey( - relay = relay, - pubkey = user(key).pubkeyHex, - since = since?.get(relay)?.time, - ) - } - } else { - Log.d("MarmotDbg") { "AccountGiftWrapsEoseManager.updateFilter: account not writeable, skipping" } - emptyList() + if (!key.account.isWriteable()) { + windowLoad.setExpectedRelays(emptySet()) + return emptyList() + } + val relays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays(relays.toSet()) + val sinceTime = DmHistoryTuning.recentBoundary() + DmRelayLog.log("giftwrap.live", key.account) + Log.d(TAG) { "[giftwrap.live] REQ since=$sinceTime (no until) on ${relays.size} relay(s): ${relays.map { it.url }}" } + return relays.flatMap { relay -> + filterGiftWrapsToPubkey(relay = relay, pubkey = user(key).pubkeyHex, since = sinceTime) } } - val userJobMap = mutableMapOf>() + private val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) override fun newSub(key: AccountQueryState): Subscription { val user = user(key) + windowLoad.startLoading(key.account.scope) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( key.account.scope.launch(Dispatchers.IO) { - key.account.dmRelays.flow.collectLatest { - invalidateFilters() - } + key.account.dmRelays.flow + .collectLatest { invalidateFilters() } }, ) - return super.newSub(key) + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) } override fun endSub( @@ -90,4 +104,8 @@ class AccountGiftWrapsEoseManager( super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } + + companion object { + private const val TAG = "DMPagination" + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt new file mode 100644 index 0000000000..93f94d2af0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps + +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** + * Loads the account's NIP-17 gift-wrap **history** — everything older than the one-week live tail + * ([AccountGiftWrapsEoseManager]) — by **`until`+`limit` paging, per relay, on demand**. + * + * There is no proactive walk: each relay advances exactly one page when the UI calls [advance] for it, + * and then **parks** at its window limit. The on-screen window-limit markers are the drivers — a relay + * pages only while its marker is visible, and keeps paging (page after page) as long as it stays visible + * (see the rooms-list / conversation feed views). So a spam-dense relay never floods: the user has to + * scroll through its messages to pull more, and nothing is fetched while its marker is off screen. + * + * The per-relay cursors live on the account's [ChatroomList][com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList] + * (so they share the lifetime of the cached gift-wraps); this class binds the single-active + * [BackwardRelayPager] orchestrator to them on [newSub], builds the gift-wrap REQ filters, and forwards + * relay callbacks into the pager. A relay is *done* once it answers an empty page; one that won't answer + * (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted] flips once every relay + * is either done or stalled. + */ +class AccountGiftWrapsHistoryEoseManager( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + private val pager = BackwardRelayPager("giftwrap.history") + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + if (!key.account.isWriteable()) return emptyList() + // Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a + // page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't + // re-REQ it — it stays parked until the UI advances it again. + val relays = key.account.dmRelays.flow.value + val armed = pager.armedRelays(relays) + if (armed.isEmpty()) return emptyList() + DmRelayLog.log("giftwrap.history", key.account) + return armed.flatMap { relay -> + val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList() + Log.d(TAG) { "[giftwrap.history] REQ ${relay.url} until ${daysAgo(until)}d, limit=${pager.pageLimit}" } + filterGiftWrapsToPubkey(relay = relay, pubkey = key.account.userProfile().pubkeyHex, since = null, until = until, limit = pager.pageLimit) + } + } + + /** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */ + fun advanceAll() { + if (pager.advanceAll()) { + Log.d(TAG) { "[giftwrap.history] advanceAll (empty-feed bootstrap)" } + invalidateFilters() + } + } + + override fun newSub(key: AccountQueryState): Subscription { + // Repoint the single-active orchestrator at this account's gift-wrap cursors (on its ChatroomList) + // and the relays it fans out to, refreshing the display flows from the restored progress. + pager.bind(key.account.chatroomList.giftWrapHistory, key.account.scope) { key.account.dmRelays.flow.value } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: AccountQueryState): SubscriptionListener { + // A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to + // another account; gate the pager (single-active) on whether it's still bound to THIS account's + // cursors so a late callback can't move another account's cursors. newEose runs regardless. + val myCursors = key.account.chatroomList.giftWrapHistory + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { + Log.d(TAG) { "[giftwrap.history] ${relay.url} reached the bottom (done)" } + } + // No auto-advance: the relay parks here until its marker asks for the next page. + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } + + companion object { + private const val TAG = "DMPagination" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index 9fc8f3aa96..dd6384eb51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -58,8 +58,23 @@ fun RefreshingChatroomFeedView( onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, scrollStateKey: String? = null, + // Opt-in hook handed the feed's scroll state, so a specific screen (e.g. private DMs) can + // attach scroll-driven loading. No-op for the public-chat / channel callers that don't paginate. + listStateObserver: @Composable (LazyListState) -> Unit = {}, + // Optional footer rendered at the oldest end of the thread (a "load more" / spinner affordance). + // Null for callers that load their whole history at once (public chats / channels). + olderBoundary: (@Composable () -> Unit)? = null, + // Optional per-gap hook: invoked between each message and its next-older neighbour with their + // createdAt bounds, so a caller (private DMs) can draw per-relay paging markers at the depth each + // relay has reached. No-op for callers without per-relay progress. + markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, + // Optional hoisted load driver: handed the loaded message list and its scroll state once (above the + // LazyColumn), so a caller (private DMs) can drive demand-driven paging off viewport visibility + // rather than per-row composition. No-op for callers that don't paginate. + sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, ) { SaveableFeedState(feedContentState, scrollStateKey) { listState -> + listStateObserver(listState) RenderChatFeedView( feedContentState, accountViewModel, @@ -69,6 +84,9 @@ fun RefreshingChatroomFeedView( onWantsToReply, onWantsToEditDraft, avoidDraft, + olderBoundary, + markersInGap, + sentinels, ) } } @@ -83,6 +101,9 @@ fun RenderChatFeedView( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, + olderBoundary: (@Composable () -> Unit)? = null, + markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, + sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, ) { val feedState by feed.feedContent.collectAsStateWithLifecycle() @@ -110,6 +131,9 @@ fun RenderChatFeedView( onWantsToReply, onWantsToEditDraft, avoidDraft, + olderBoundary, + markersInGap, + sentinels, ) } } @@ -126,9 +150,16 @@ fun ChatFeedLoaded( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, + olderBoundary: (@Composable () -> Unit)? = null, + markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, + sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, ) { val items by loaded.feed.collectAsStateWithLifecycle() + // Hoisted load driver (above the LazyColumn): pages each relay off viewport visibility, so feed + // reorders no longer re-fire paging. The per-gap markers below are pure UI. + sentinels?.invoke(items.list, listState) + LaunchedEffect(items.list.firstOrNull()) { if (listState.firstVisibleItemIndex <= 1) { listState.animateScrollToItem(0) @@ -169,7 +200,24 @@ fun ChatFeedLoaded( ) NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item) + + // Per-relay paging markers belonging in the gap toward the next-older message. With the + // reverse layout this draws just above the message (the older side), so a relay's marker + // appears right below the oldest message it has reached and slides down as it pages. + markersInGap?.invoke( + item.event?.createdAt, + items.list + .getOrNull(index + 1) + ?.event + ?.createdAt, + ) } } + + // Reverse layout: a trailing item sits at the highest index, i.e. the visual TOP (oldest end). + // That's where the caller's "load more" affordance / spinner lives. + if (olderBoundary != null) { + item(key = "olderBoundary") { olderBoundary() } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index d0ac5a38e1..0d8d9dede0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -106,8 +107,12 @@ fun ChatroomMessageCompose( onScrollToNote: ((Note) -> Unit)? = null, shouldHighlight: Boolean = false, onHighlightFinished: (() -> Unit)? = null, + // Replaces the generic "post not found" blank while baseNote's event hasn't loaded. Used for + // reply quotes inside a DM, where the target is simply older than the loaded window (see + // LoadingReplyNote). Null keeps the default blank for every other caller. + onBlank: (@Composable () -> Unit)? = null, ) { - WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) { + val onFound: @Composable () -> Unit = { WatchBlockAndReport( note = baseNote, showHiddenWarning = false, @@ -140,6 +145,12 @@ fun ChatroomMessageCompose( } } } + + if (onBlank != null) { + WatchNoteEvent(baseNote = baseNote, onNoteEventFound = onFound, onBlank = onBlank, accountViewModel = accountViewModel) + } else { + WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav, onNoteEventFound = onFound) + } } @Composable @@ -396,9 +407,23 @@ private fun RenderReply( } } - replyTo.value?.let { note -> + replyTo.value?.let { replyNote -> + // For a DM, a reply target that hasn't arrived isn't lost — it's older than the loaded + // window (and for gift wraps can't be fetched by id). Swap the generic blank for one that + // walks history backward until it surfaces. Pick the pager by the PARENT's protocol; leave + // public chats / marmot groups (not a DM event here) on the default blank. + val replyBlank: (@Composable () -> Unit)? = + when (note.event) { + is ChatMessageEvent, is ChatMessageEncryptedFileHeaderEvent -> { + { LoadingReplyNote(DmReplyProtocol.NIP17, accountViewModel) } + } + is PrivateDmEvent -> { + { LoadingReplyNote(DmReplyProtocol.NIP04, accountViewModel) } + } + else -> null + } ChatroomMessageCompose( - baseNote = note, + baseNote = replyNote, routeForLastRead = null, innerQuote = true, parentBackgroundColor = bgColor, @@ -407,6 +432,7 @@ private fun RenderReply( onWantsToReply = onWantsToReply, onWantsToEditDraft = onWantsToEditDraft, onScrollToNote = onScrollToNote, + onBlank = replyBlank, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt new file mode 100644 index 0000000000..7b6ab94391 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt @@ -0,0 +1,32 @@ +/* + * 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.feed + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * The Android locale date formatter for the shared [com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard] + * — passed in so the shared (KMP) card carries no `java.time` dependency. Formats a paging reach point + * (epoch seconds) to a short month-year label, e.g. "Jun 2026". + */ +fun formatHistoryReachDate(epochSeconds: Long): String = SimpleDateFormat("MMM yyyy", Locale.getDefault()).format(Date(epochSeconds * 1000)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt new file mode 100644 index 0000000000..9c5e3e024e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt @@ -0,0 +1,222 @@ +/* + * 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.feed + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryRelayDialog +import com.vitorpamplona.amethyst.commons.ui.feeds.historySubtitle +import com.vitorpamplona.amethyst.commons.ui.feeds.incompleteSubtitle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter + +/** Which DM history pager backs the conversation an unloaded reply belongs to. */ +enum class DmReplyProtocol { + // NIP-17 gift wraps. The rumor id of the reply target is NOT queryable on relays (only the outer + // 1059 wrap id is), so the only way to surface it is to keep paging gift-wrap history until the wrap + // carrying it is decrypted — hence we drive the account-wide gift-wrap history pager. + NIP17, + + // NIP-04 legacy DMs (kind 4). Paged per relay for the open conversation. + NIP04, +} + +/** + * The inner-quote placeholder for a reply whose target message has not been paged in yet — used in + * place of the generic [com.vitorpamplona.amethyst.ui.note.BlankNote] ("post not found") that the main + * feeds show. A reply target inside a conversation isn't *missing*, it's simply older than the window + * loaded so far; for gift wraps it can't even be fetched by id. So instead of declaring it lost, this + * card actively walks the conversation's history backward — kicking the protocol's `loadMore` each time + * the previous page settles — until either the target decrypts (the surrounding + * [com.vitorpamplona.amethyst.ui.note.WatchNoteEvent] crossfades the real message in and disposes this) + * or that protocol's history runs dry, at which point it settles into the terminal "not found" text. + * + * It runs regardless of scroll position (no oldest-end gate like the scroll-driven loader) precisely so + * that opening a thread and seeing a reply to something off-screen pulls that something in on its own. + * The drive loop is idempotent and gated on the pager's own `loadingMore`/`exhausted`, so several + * unloaded replies on screen — and the scroll loader — all coalesce onto the same paging window. + */ +@Composable +fun LoadingReplyNote( + protocol: DmReplyProtocol, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } + + val loadingFlow: StateFlow = + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.loadingMore + DmReplyProtocol.NIP04 -> nip04History.loadingMore + } + val statusFlow: StateFlow = + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.status + DmReplyProtocol.NIP04 -> nip04History.status + } + val protocolTag = + when (protocol) { + DmReplyProtocol.NIP17 -> "NIP-17" + DmReplyProtocol.NIP04 -> "NIP-04" + } + + // One snapshot collector instead of five; the fields below are plain reads off it (downstream unchanged). + val status by statusFlow.collectAsStateWithLifecycle() + val exhausted = status.exhausted + val relayCount = status.relayCount + val stalledCount = status.stalledCount + val reachedBack = status.reachedBack + val relayProgress = status.relayProgress + + LaunchedEffect(protocol, loadingFlow, statusFlow) { + // Step the next, older page whenever the previous one has settled and history isn't exhausted. + // The target may surface mid-page (this composable then leaves composition and cancels us); if + // not, we keep walking until the protocol bottoms out and the filter stops passing. + combine(loadingFlow, statusFlow) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { + Log.d("DMPagination") { "reply blank: widen → $protocol advanceAll (searching for unloaded reply)" } + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.advanceAll() + DmReplyProtocol.NIP04 -> nip04History.advanceAll() + } + } + } + + // Tapping opens the same per-relay popup the history card uses, so when the search gives up the user + // can see exactly which relays were reached and which stalled. Empty progress keeps it non-interactive. + var showRelays by remember { mutableStateOf(false) } + if (showRelays) { + DmHistoryRelayDialog(protocolTag, relayProgress, ::formatHistoryReachDate) { showRelays = false } + } + + // Same chrome as DmHistoryLoadingCard (the older-history status card at the oldest end) so an + // unloaded reply reads as the same kind of "reaching back into history" state, just inline in the + // quote: rounded translucent surface, a spinner-in-a-box, then the status line. + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .then(if (relayProgress.isNotEmpty()) Modifier.clickable { showRelays = true } else Modifier), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + tonalElevation = 2.dp, + ) { + // When the walk gives up it splits the same way the history card does: some relays stalled + // (couldn't reach them — the message may still be out there) vs every relay genuinely bottomed + // out (it really isn't in your history). Either way we say what happened instead of a bare glyph. + val stalledOut = exhausted && stalledCount > 0 + Crossfade(targetState = exhausted, animationSpec = tween(500), label = "loadingReplyState") { isExhausted -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { + when { + stalledOut -> + // Stalled-out: same red "…" the per-relay dialog and history card use for unreachable. + Text( + "…", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + ) + isExhausted -> + // Genuinely searched everything and it isn't there. + Text( + "✕", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text( + text = + if (isExhausted) { + stringRes(R.string.chats_reply_not_found) + } else { + stringRes(R.string.chats_reply_searching_history) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // While loading: which protocol, how many relays, how far back. When it gives up: either + // "N relays unreachable · tap to see which" (stalled) or "searched every relay · tap to see". + Text( + text = + when { + stalledOut -> incompleteSubtitle(stalledCount) + isExhausted -> stringRes(R.string.chats_reply_searched) + else -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack, ::formatHistoryReachDate) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 06d9bf0532..4632448358 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -26,12 +26,28 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -40,15 +56,23 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @Composable @@ -134,6 +158,47 @@ fun ChatroomView( ) } +/** + * Bootstraps history when the conversation has no messages yet (the live tail came back empty for a + * thread whose newest message is older than a week). There's nothing on screen to host the per-relay + * window-limit markers that normally drive paging, so while the feed is empty we step every relay one + * page at a time (each protocol independently, gated on its own loader) until messages appear — at which + * point the on-screen markers take over — or the protocol is exhausted. Once the feed is Loaded this + * does nothing; paging is then purely demand-driven by the markers' visibility. + */ +@Composable +private fun BootstrapHistoryWhenEmpty( + feedContentState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } + val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + // Empty only (never the transient Loading navigation flashes through), and debounced below, so + // re-opening a conversation that has messages doesn't kick a hunt. + val needsBootstrap = feedState is FeedState.Empty + + LaunchedEffect(needsBootstrap, giftWrapsHistory) { + if (!needsBootstrap) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) + combine(giftWrapsHistory.loadingMore, giftWrapsHistory.status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { giftWrapsHistory.advanceAll() } + } + LaunchedEffect(needsBootstrap, nip04History) { + if (!needsBootstrap) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) + combine(nip04History.loadingMore, nip04History.status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { nip04History.advanceAll() } + } +} + +// Ignore the transient empty feed that navigation flashes through before messages re-appear. +private const val BOOTSTRAP_DEBOUNCE_MS = 1200L + @Composable fun ChatroomViewUI( room: ChatroomKey, @@ -145,6 +210,50 @@ fun ChatroomViewUI( WatchLifecycleAndUpdateModel(feedViewModel) ChatroomFilterAssemblerSubscription(room, accountViewModel.dataSources().chatroom, accountViewModel) + DisposableEffect(room) { + Log.d("DMPagination") { "convo: OPEN room=${room.hashCode()}" } + onDispose { Log.d("DMPagination") { "convo: CLOSE room=${room.hashCode()}" } } + } + + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } + val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle() + // One atomic snapshot per protocol (exhausted + relays + reached + per-relay progress) instead of six + // separate collectors — the status card and the per-relay markers read all of it together anyway. + val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle() + val nip04Status by nip04History.status.collectAsStateWithLifecycle() + val user = accountViewModel.userProfile() + + // Both protocols' per-relay window limits, each carrying the advance() that pulls its own next page. + // Placed in the stream as sentinels (see RelayReachMarkers): a relay pages only while its + // marker is on screen, and keeps paging while it stays there. A protocol drops out once exhausted. + val limits = + remember(nip04Status, giftWrapsStatus, user) { + buildList { + if (!giftWrapsStatus.exhausted) { + giftWrapsStatus.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) }) + } + } + if (!nip04Status.exhausted) { + nip04Status.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) }) + } + } + } + } + val nip17Name = stringResource(R.string.chats_history_proto_nip17) + val nip04Name = stringResource(R.string.chats_history_proto_nip04) + + // The relays behind a tapped in-stream "Relay sync" marker; non-null shows the detail popup. + var syncDetail by remember { mutableStateOf?>(null) } + syncDetail?.let { detail -> + RelayReachDetailDialog(detail, ::formatHistoryReachDate) { syncDetail = null } + } + + BootstrapHistoryWhenEmpty(feedViewModel.feedState, accountViewModel) + Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) @@ -163,6 +272,32 @@ fun ChatroomViewUI( avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, + // One status card per protocol at the oldest end: each shows what it's reaching for + // while it pages and crossfades to "All caught up" when that protocol runs dry. + olderBoundary = { + Column { + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsStatus.exhausted, giftWrapsStatus.relayCount, giftWrapsStatus.stalledCount, giftWrapsStatus.reachedBack, giftWrapsStatus.relayProgress, ::formatHistoryReachDate) + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Status.exhausted, nip04Status.relayCount, nip04Status.stalledCount, nip04Status.reachedBack, nip04Status.relayProgress, ::formatHistoryReachDate) + } + }, + // Each relay's window-limit marker, placed at its reached cursor (pure UI). Hidden once + // both protocols are exhausted. + markersInGap = + if (limits.isEmpty()) { + null + } else { + { newer, older -> RelayReachMarkers(limits, newer, older) { syncDetail = it } } + }, + // The hoisted load driver that pulls each relay's next page while its marker is on screen, + // off viewport visibility (see RelayReachSentinels) so feed reorders don't re-page. + sentinels = + if (limits.isEmpty()) { + null + } else { + { items, listState -> + RelayReachSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt } + } + }, ) } @@ -183,3 +318,16 @@ fun ChatroomViewUI( ) } } + +private fun reachState(p: RelayPagingProgress): RelayReachState = + when { + p.done -> RelayReachState.DONE + p.stalled -> RelayReachState.STALLED + else -> RelayReachState.REACHING + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .substringAfter("://") + .trimEnd('/') + .substringBefore('/') diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index b5cc945fcd..9366082074 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -36,9 +36,16 @@ class ChatroomQueryState( class ChatroomFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + // NIP-04 live tail: the recent week, always open at the top. + val nip04 = ChatroomNip04SubAssembler(client, ::allKeys) + + // NIP-04 history: older DMs, paged backward by until+limit, independently of gift wraps. + val nip04History = ChatroomNip04HistorySubAssembler(client, ::allKeys) + val group = listOf( - ChatroomFilterSubAssembler(client, ::allKeys), + nip04, + nip04History, ) override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt new file mode 100644 index 0000000000..4cbe77bffb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt @@ -0,0 +1,155 @@ +/* + * 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.privateDM.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** + * Loads older NIP-04 DMs (kind 4) for one conversation by **`until`+`limit` paging, per relay, on + * demand**. Each relay advances exactly one page when the conversation's on-screen window-limit marker + * for that relay asks ([advance]); otherwise it parks. Nothing is walked proactively — a relay pages + * only while its marker is visible and keeps paging while it stays visible. + * + * The per-relay cursors live on the conversation's [Chatroom][com.vitorpamplona.amethyst.commons.model.privateChats.Chatroom] + * (so reopening the room keeps its progress); this class binds the single-active [BackwardRelayPager] + * orchestrator to the open room's cursors on [newSub], builds the (per-relay scoped) NIP-04 REQ + * filters, and forwards relay callbacks into the pager. A relay is *done* once it answers an empty page; + * one that won't answer (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted] + * flips once every relay is either done or stalled. + */ +class ChatroomNip04HistorySubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + private val pager = BackwardRelayPager("convo.nip04.history") + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + override fun user(key: ChatroomQueryState) = key.account.userProfile() + + override fun list(key: ChatroomQueryState) = key.listId + + // This conversation's persistent paging cursors, held on its Chatroom (per account + room). + private fun cursorsFor(key: ChatroomQueryState) = + key.account.chatroomList + .getOrCreatePrivateChatroom(key.room) + .nip04History + + override fun updateFilter( + key: ChatroomQueryState, + since: SincePerRelayMap?, + ): List? { + val relays = nip04DmRelayRouting(key.room.users, key.account) + if (!key.account.isWriteable() || relays == null) return emptyList() + + // Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked + // relay keeps the same filter here, so re-assembly (another relay advancing) doesn't re-REQ it. + val armed = pager.armedRelays(relays.all).toSet() + if (armed.isEmpty()) return emptyList() + DmRelayLog.log("convo.nip04.history", key.account) + val scoped = + Nip04DmRelayRouting( + toMeRelays = relays.toMeRelays.filterKeys { it in armed }, + fromMeRelays = relays.fromMeRelays.filterKeys { it in armed }, + ) + return filterNip04DMsHistory(key.account, scoped, pager.pageLimit) { relay -> + pager.requestedUntilFor(relay) + } + } + + /** Steps a single [relay] to its next, older page for the open conversation. Driven by its marker. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. For a thread too short to scroll. */ + fun advanceAll() { + if (pager.advanceAll()) { + Log.d("DMPagination") { "[convo.nip04.history] advanceAll (empty-thread bootstrap)" } + invalidateFilters() + } + } + + override fun newSub(key: ChatroomQueryState): Subscription { + // Repoint the single-active orchestrator at this conversation's cursors (on its Chatroom) and the + // relays it fans out to, refreshing the display flows from the restored progress. + pager.bind(cursorsFor(key), key.account.scope) { nip04DmRelayRouting(key.room.users, key.account)?.all } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: ChatroomQueryState): SubscriptionListener { + // A just-backgrounded room's subscription can still deliver after the orchestrator rebinds to + // another room; gate the pager (single-active) on whether it's still bound to THIS room's cursors + // so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway. + val myCursors = cursorsFor(key) + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { + Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} reached the bottom (done)" } + } + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt similarity index 53% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt index 0d451daecf..f7c8bfb686 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt @@ -20,26 +20,55 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager 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.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow -class ChatroomFilterSubAssembler( +/** + * Always-on **live tail** for one conversation's NIP-04 DMs (kind 4). A fixed one-week floor, no + * upper bound, never widens — older history is loaded by [ChatroomNip04HistorySubAssembler] in + * bounded slices that follow the gift-wrap history window. + */ +class ChatroomNip04SubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserAndFollowListEoseManager(client, allKeys) { + private val windowLoad = WindowLoadTracker("convo.nip04.live") + val loadingMore: StateFlow = windowLoad.loading + override fun updateFilter( key: ChatroomQueryState, since: SincePerRelayMap?, ): List? = if (key.account.isWriteable()) { - filterNip04DMs(key.room.users, key.account, since) + val sinceTime = DmHistoryTuning.recentBoundary() + val filters = filterNip04DMs(key.room.users, key.account, sinceTime) + windowLoad.setExpectedRelays(filters?.mapTo(mutableSetOf()) { it.relay } ?: emptySet()) + DmRelayLog.log("convo.nip04.live", key.account) + Log.d("DMPagination") { "[convo.nip04.live] REQ since=$sinceTime (no until) on ${filters?.size ?: 0} relay-filter(s): ${filters?.map { it.relay.url }?.distinct()}" } + filters } else { + windowLoad.setExpectedRelays(emptySet()) emptyList() } override fun user(key: ChatroomQueryState) = key.account.userProfile() override fun list(key: ChatroomQueryState) = key.listId + + override fun newSub(key: ChatroomQueryState): Subscription { + windowLoad.startLoading(key.account.scope) + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index ec277266de..5072ce8db0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -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 @@ -30,19 +29,55 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -fun filterNip04DMs( +/** + * Where a conversation's NIP-04 DMs flow, resolved via the outbox model and **scoped per relay** so + * every filter only names the keys that actually own the relay it is sent to. + * + * Each map is `relay -> the counterpart keys to name in that relay's filter`: + * - [toMeRelays] queries messages **to me** (`authors=[those keys], #p=[me]`). A relay appears here + * when it is my inbox (then the key set is the whole group) and/or a counterpart's outbox (then + * the key set is just the counterparts who publish there). + * - [fromMeRelays] queries messages **from me** (`authors=[me], #p=[those keys]`). A relay appears + * here when it is my outbox (then the key set is the whole group) and/or a counterpart's inbox + * (then the key set is just the counterparts who read there). + * + * Scoping the key set per relay is what keeps us from sending, e.g., `authors=[bob]` to a relay that + * is only charlie's — a filter that relay has no reason to serve. + */ +class Nip04DmRelayRouting( + val toMeRelays: Map>, + val fromMeRelays: Map>, +) { + val all: Set get() = toMeRelays.keys + fromMeRelays.keys +} + +private fun addAll( + map: MutableMap>, + relays: Collection, + keys: Collection, +) = relays.forEach { map.getOrPut(it) { mutableSetOf() }.addAll(keys) } + +fun nip04DmRelayRouting( group: Set?, account: Account?, - since: SincePerRelayMap?, -): List? { +): Nip04DmRelayRouting? { if (group.isNullOrEmpty() || account == null) return null val userOutboxRelays = account.homeRelays.flow.value val userInboxRelays = account.dmRelays.flow.value - val groupOutboxRelays = mutableSetOf() - val groupInboxRelays = mutableSetOf() + // relay -> counterpart keys whose messages-to-me we ask that relay for (authors set, #p=[me]). + val toMe = mutableMapOf>() + // relay -> counterpart keys whose copy of my messages we ask that relay for (#p set, authors=[me]). + val fromMe = mutableMapOf>() + // My own relays carry the whole conversation: my inbox holds everyone's messages to me, my outbox + // holds all of mine. Both filters name the full group on these relays. + addAll(toMe, userInboxRelays, group) + addAll(fromMe, userOutboxRelays, group) + + // Each counterpart's own relays only get a filter naming that counterpart: their outbox (where + // they publish their messages to me) and their inbox (where they keep my messages to them). group.forEach { val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it) val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent) @@ -53,42 +88,81 @@ fun filterNip04DMs( ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } ?: emptyList() - groupOutboxRelays.addAll(outbox) - val inbox = authorHomeRelayEvent?.readRelaysNorm()?.ifEmpty { null } ?: LocalCache.getUserIfExists(it)?.allUsedRelaysOrNull() ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } ?: emptyList() - groupInboxRelays.addAll(inbox) + addAll(toMe, outbox, listOf(it)) + addAll(fromMe, inbox, listOf(it)) } - val toMeRelays = (userInboxRelays + groupOutboxRelays) - val fromMeRelays = (userOutboxRelays + groupInboxRelays) - - return toMeRelays.map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = listOf(PrivateDmEvent.KIND), - authors = group.toList(), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = since?.get(it)?.time, - ), - ) - } + - fromMeRelays.map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - tags = mapOf("p" to group.toList()), - since = since?.get(it)?.time, - ), - ) - } + return Nip04DmRelayRouting(toMe, fromMe) } + +private fun toMeFilter( + relay: NormalizedRelayUrl, + authors: Set, + account: Account, + since: Long?, + until: Long?, + limit: Int?, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = authors.toList(), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + since = since, + until = until, + limit = limit, + ), +) + +private fun fromMeFilter( + relay: NormalizedRelayUrl, + pTags: Set, + account: Account, + since: Long?, + until: Long?, + limit: Int?, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + tags = mapOf("p" to pTags.toList()), + since = since, + until = until, + limit = limit, + ), +) + +/** Live-tail filters: everything since [windowStart], open-ended at the top (new messages keep arriving). */ +fun filterNip04DMs( + group: Set?, + account: Account?, + windowStart: Long, +): List? { + if (group.isNullOrEmpty() || account == null) return null + val relays = nip04DmRelayRouting(group, account) ?: return null + return relays.toMeRelays.map { (relay, authors) -> toMeFilter(relay, authors, account, since = windowStart, until = null, limit = null) } + + relays.fromMeRelays.map { (relay, pTags) -> fromMeFilter(relay, pTags, account, since = windowStart, until = null, limit = null) } +} + +/** + * History filters: a bounded backward page per relay. Each relay is asked for [limit] events older + * than [untilFor]`(relay)` (no `since`), so it can be paged down to empty independently. The author / + * `#p` key set per relay comes straight from [relays], so each relay only sees the keys it owns. + */ +fun filterNip04DMsHistory( + account: Account, + relays: Nip04DmRelayRouting, + limit: Int, + untilFor: (NormalizedRelayUrl) -> Long?, +): List = + relays.toMeRelays.map { (relay, authors) -> toMeFilter(relay, authors, account, since = null, until = untilFor(relay), limit = limit) } + + relays.fromMeRelays.map { (relay, pTags) -> fromMeFilter(relay, pTags, account, since = null, until = untilFor(relay), limit = limit) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index 7cf22d65e9..5feb61404b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -122,7 +122,7 @@ class ChatroomListNewFeedFilter( if (room != null && ( newNote.author?.pubkeyHex != me.pubkeyHex && - room.senderIntersects(followingKeySet) && + !room.senderIntersects(followingKeySet) && !account.chatroomList.hasSentMessagesTo(roomKey) ) && !account.isAllHidden(roomKey.users) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 2b9c534502..489b241971 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -35,9 +35,16 @@ class ChatroomListState( class ChatroomListFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + // NIP-04 live tail: the recent week, always open at the top. + val nip04 = ChatroomListNip04SubAssembler(client, ::allKeys) + + // NIP-04 history: older DMs, paged backward by until+limit, independently of gift wraps. + val nip04History = ChatroomListNip04HistorySubAssembler(client, ::allKeys) + val group = listOf( - DMsFromUserFilterSubAssembler(client, ::allKeys), + nip04, + nip04History, FollowingPublicChatSubAssembler(client, ::allKeys), FollowingEphemeralChatSubAssembler(client, ::allKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt new file mode 100644 index 0000000000..73a31dab16 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** + * Loads older NIP-04 DMs (kind 4) for the rooms list by **`until`+`limit` paging, per relay, on + * demand** — the same model as the gift-wrap history loader + * ([com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager]), + * across the account's home (outbox, *from me*) + DM (inbox, *to me*) relays. Each relay advances one + * page when its on-screen window-limit marker asks ([advance]); otherwise it parks. Nothing is walked + * proactively. The per-relay cursors live on the account's + * [ChatroomList][com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList]; the single-active + * [BackwardRelayPager] orchestrator binds to them on [newSub]. + */ +class ChatroomListNip04HistorySubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + private fun allRelays(account: Account) = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() + + private val pager = BackwardRelayPager("rooms.nip04.history") + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + override fun user(key: ChatroomListState) = key.account.userProfile() + + override fun updateFilter( + key: ChatroomListState, + since: SincePerRelayMap?, + ): List? { + val user = user(key) + if (!key.account.isWriteable()) return emptyList() + val homeRelays = key.account.homeRelays.flow.value + val dmRelays = key.account.dmRelays.flow.value + val armed = pager.armedRelays((homeRelays + dmRelays).toSet()) + if (armed.isEmpty()) return emptyList() + DmRelayLog.log("rooms.nip04.history", key.account) + return armed.flatMap { relay -> + val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList() + buildList { + if (relay in homeRelays) add(filterNip04DMsFromMe(user, relay, since = null, until = until, limit = pager.pageLimit)) + if (relay in dmRelays) add(filterNip04DMsToMe(user, relay, since = null, until = until, limit = pager.pageLimit)) + } + } + } + + /** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */ + fun advanceAll() { + if (pager.advanceAll()) { + Log.d("DMPagination") { "[rooms.nip04.history] advanceAll (empty-feed bootstrap)" } + invalidateFilters() + } + } + + override fun newSub(key: ChatroomListState): Subscription { + // Repoint the single-active orchestrator at this account's rooms-list NIP-04 cursors (on its + // ChatroomList) and the relays it fans out to, refreshing the flows from the restored progress. + pager.bind(key.account.chatroomList.nip04History, key.account.scope) { allRelays(key.account) } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: ChatroomListState): SubscriptionListener { + // A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to + // another account; gate the pager (single-active) on whether it's still bound to THIS account's + // cursors so a late callback can't move another account's cursors. newEose runs regardless. + val myCursors = key.account.chatroomList.nip04History + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { + Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} reached the bottom (done)" } + } + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt index 410e03d59b..cd712abcaa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt @@ -20,60 +20,79 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager 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.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -class DMsFromUserFilterSubAssembler( +/** + * Always-on **live tail** for the account's NIP-04 DMs (kind 4) in the rooms list. Mirrors the + * gift-wrap live tail: a fixed one-week floor, no upper bound, never widens. Older NIP-04 history is + * loaded by [ChatroomListNip04HistorySubAssembler] in bounded slices. + */ +class ChatroomListNip04SubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + private val windowLoad = WindowLoadTracker("rooms.nip04.live") + val loadingMore: StateFlow = windowLoad.loading + override fun updateFilter( key: ChatroomListState, since: SincePerRelayMap?, ): List? = if (key.account.isWriteable()) { - key.account.homeRelays.flow.value.map { - filterNip04DMsFromMe(key.account.userProfile(), it, since?.get(it)?.time) - } + - key.account.dmRelays.flow.value.map { - filterNip04DMsToMe(key.account.userProfile(), it, since?.get(it)?.time) - } + val homeRelays = key.account.homeRelays.flow.value + val dmRelays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) + val sinceTime = DmHistoryTuning.recentBoundary() + DmRelayLog.log("rooms.nip04.live", key.account) + Log.d("DMPagination") { "[rooms.nip04.live] REQ since=$sinceTime (no until) fromMe(outbox)=${homeRelays.map { it.url }} toMe(inbox)=${dmRelays.map { it.url }}" } + homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, sinceTime) } + + dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, sinceTime) } } else { + windowLoad.setExpectedRelays(emptySet()) emptyList() } override fun user(key: ChatroomListState) = key.account.userProfile() - val userJobMap = mutableMapOf>() + private val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) override fun newSub(key: ChatroomListState): Subscription { val user = user(key) + windowLoad.startLoading(key.account.scope) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( key.account.scope.launch(Dispatchers.IO) { - key.account.homeRelays.flow.collectLatest { - invalidateFilters() - } + key.account.homeRelays.flow + .collectLatest { invalidateFilters() } }, key.account.scope.launch(Dispatchers.IO) { - key.account.dmRelays.flow.collectLatest { - invalidateFilters() - } + key.account.dmRelays.flow + .collectLatest { invalidateFilters() } }, ) - return super.newSub(key) + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) } override fun endSub( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt index de96b9f2aa..c000c246d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt @@ -30,6 +30,8 @@ fun filterNip04DMsFromMe( user: User, relay: NormalizedRelayUrl, since: Long?, + until: Long? = null, + limit: Int? = null, ): RelayBasedFilter = RelayBasedFilter( relay = relay, @@ -38,5 +40,7 @@ fun filterNip04DMsFromMe( kinds = listOf(PrivateDmEvent.KIND), authors = listOf(user.pubkeyHex), since = since, + until = until, + limit = limit, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt index dcfdab1602..4b1148e066 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt @@ -30,6 +30,8 @@ fun filterNip04DMsToMe( user: User, relay: NormalizedRelayUrl, since: Long?, + until: Long? = null, + limit: Int? = null, ): RelayBasedFilter = RelayBasedFilter( relay = relay, @@ -38,5 +40,7 @@ fun filterNip04DMsToMe( kinds = listOf(PrivateDmEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), since = since, + until = until, + limit = limit, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 300f36eda9..a7612c4402 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -25,15 +25,29 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty @@ -44,15 +58,25 @@ import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import java.io.Serializable @Composable @@ -62,6 +86,10 @@ fun ChatroomListFeedView( accountViewModel: AccountViewModel, nav: INav, ) { + DisposableEffect(Unit) { + Log.d("DMPagination") { "rooms.list: OPEN" } + onDispose { Log.d("DMPagination") { "rooms.list: CLOSE" } } + } RefresheableBox(feedContentState, true) { SaveableFeedContentState(feedContentState, scrollStateKey) { listState -> CrossFadeState(feedContentState, listState, accountViewModel, nav) @@ -78,6 +106,24 @@ private fun CrossFadeState( ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + // History is exhausted only once BOTH DM protocols have paged to their end (each stops when a + // round of until+limit pages brings nothing back). Until then an empty feed means "still filling", + // not "no conversations" — keep the spinner up rather than flash empty. + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History } + val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle() + val nip04Status by nip04History.status.collectAsStateWithLifecycle() + val historyExhausted = giftWrapsStatus.exhausted && nip04Status.exhausted + + // A *genuinely* empty list has no rows to host the per-relay window-limit markers, so we step every + // relay one page at a time to hunt for the first rooms. Gated on FeedState.Empty only (never the + // transient Loading that navigation flashes through) and debounced, so re-opening Messages with rooms + // already loaded does NOT kick a hunt. Once rooms appear the markers take over, demand-driven. + val user = accountViewModel.userProfile() + val bootstrap = feedState is FeedState.Empty + BootstrapHistoryWhenEmpty(bootstrap, giftWrapsHistory.loadingMore, giftWrapsHistory.status) { giftWrapsHistory.advanceAll() } + BootstrapHistoryWhenEmpty(bootstrap, nip04History.loadingMore, nip04History.status) { nip04History.advanceAll() } + CrossfadeIfEnabled( targetState = feedState, animationSpec = tween(durationMillis = 100), @@ -85,7 +131,11 @@ private fun CrossFadeState( ) { state -> when (state) { is FeedState.Empty -> { - FeedEmpty { feedContentState.invalidateData() } + if (historyExhausted) { + FeedEmpty { feedContentState.invalidateData() } + } else { + LoadingFeed() + } } is FeedState.FeedError -> { @@ -114,14 +164,60 @@ private fun FeedLoaded( val myPubKey = accountViewModel.userProfile().pubkeyHex + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History } + val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle() + // One atomic snapshot per protocol (exhausted + relays + reached + per-relay progress) instead of six + // separate collectors — the status card and the per-relay markers read all of it together anyway. + val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle() + val nip04Status by nip04History.status.collectAsStateWithLifecycle() + val user = accountViewModel.userProfile() + val nip17Name = stringResource(R.string.chats_history_proto_nip17) + val nip04Name = stringResource(R.string.chats_history_proto_nip04) + val oldestNip17Index = items.list.indexOfLast { it.event is ChatroomKeyable && it.event !is PrivateDmEvent } + val oldestNip04Index = items.list.indexOfLast { it.event is PrivateDmEvent } + + // Each relay's window limit, carrying the advance() that pulls its OWN next page. Placed in the list + // at its reached depth as a sentinel (see RelayReachMarkers): a relay pages only while its + // marker is on screen and keeps paging while it stays there, so a spam-dense relay never floods — + // you have to scroll through its messages to pull more. A protocol drops out once exhausted. + val limits = + remember(giftWrapsStatus, nip04Status, user) { + buildList { + if (!giftWrapsStatus.exhausted) { + giftWrapsStatus.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) }) + } + } + if (!nip04Status.exhausted) { + nip04Status.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) }) + } + } + } + } + + // Hoisted load driver: pulls each relay's next page off viewport visibility, so feed reorders + // (a live DM bumping a room) no longer re-fire paging. The markers below are pure UI. + RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index)?.createdAt() } + + // The relays behind a tapped in-stream "Relay sync" marker; non-null shows the per-relay popup so the + // terse divider isn't a dead end — every count/name is one tap from the full breakdown (which relays, + // protocol, how far back each paged). + var syncDetail by remember { mutableStateOf?>(null) } + syncDetail?.let { detail -> + RelayReachDetailDialog(detail, ::formatHistoryReachDate) { syncDetail = null } + } + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { - items( + itemsIndexed( items.list, - key = { item -> chatroomLazyKey(item, myPubKey) }, - ) { item -> + key = { _, item -> chatroomLazyKey(item, myPubKey) }, + ) { index, item -> Row(Modifier.fillMaxWidth()) { ChatroomHeaderCompose( item, @@ -133,10 +229,69 @@ private fun FeedLoaded( HorizontalDivider( thickness = DividerThickness, ) + + // Rendered unconditionally at the protocol's oldest room so the card can run its own + // "All caught up" crossfade-and-collapse when that protocol exhausts. + if (index == oldestNip17Index) { + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsStatus.exhausted, giftWrapsStatus.relayCount, giftWrapsStatus.stalledCount, giftWrapsStatus.reachedBack, giftWrapsStatus.relayProgress, ::formatHistoryReachDate) + } + if (index == oldestNip04Index) { + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Status.exhausted, nip04Status.relayCount, nip04Status.stalledCount, nip04Status.reachedBack, nip04Status.relayProgress, ::formatHistoryReachDate) + } + + // Per-relay window-limit markers/sentinels belonging in the gap toward the next-older room: + // each pulls its relay's next page while it's on screen. olderCreatedAt is null past the + // oldest loaded room, so relays that have reached the bottom of the list sit there. + RelayReachMarkers( + limits, + item.createdAt(), + items.list.getOrNull(index + 1)?.createdAt(), + ) { syncDetail = it } } } } +/** + * Bootstraps history while the rooms list is genuinely empty: steps every relay one page at a time, + * gated only on its own loader, until rooms appear or the protocol exhausts. Once rooms load this stops + * and the per-relay window-limit markers drive paging on demand. + * + * Leads with a debounce so the brief Empty/Loading flash that navigation passes through does NOT trigger + * a hunt; if [active] drops before it elapses (rooms loaded) the effect cancels and nothing pages. + */ +@Composable +private fun BootstrapHistoryWhenEmpty( + active: Boolean, + loadingMore: StateFlow, + status: StateFlow, + advanceAll: () -> Unit, +) { + LaunchedEffect(active, loadingMore, status) { + if (!active) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) + combine(loadingMore, status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { advanceAll() } + } +} + +// Ignore the transient empty feed that navigation flashes through before the rooms re-appear. +private const val BOOTSTRAP_DEBOUNCE_MS = 1200L + +private fun reachState(p: RelayPagingProgress) = + when { + p.done -> RelayReachState.DONE + p.stalled -> RelayReachState.STALLED + else -> RelayReachState.REACHING + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .substringAfter("://") + .trimEnd('/') + .substringBefore('/') + // Stable per-chatroom key — derived from chatroom identity, not the latest // message id, so reorders move the row instead of recreating it. Compose // stores LazyColumn item keys in a SaveableStateHolder, which on Android diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index 6b27c59acc..b5233c6199 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.tor import android.content.Context +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -70,6 +71,67 @@ class TorService( } } + /** + * The on-disk guard sample Arti persists between runs. + * Path: `/arti/state/state/guards.json`. + */ + private fun guardsFile() = File(File(File(artiDataDir(), "state"), "state"), "guards.json") + + /** + * Detects the wedged-guard-sample state behind the long-standing "can't + * connect to Tor" bug. + * + * On a flaky network, Arti records circuit failures past the first hop as + * "indeterminate" (it can't tell whether the guard or a later hop was at + * fault). Once a guard's indeterminate ratio crosses 0.7, Arti + * *permanently* disables it (`TooManyIndeterminateFailures`). Disabled + * guards are never re-enabled and never removed from the sample (kept for + * the 60-day confirmed lifetime), and the sample is capped at + * `max_sample_size` (60). Arti normally refills usable guards from the + * network when they drop below `min_filtered_sample_size` (20), but once + * the sample is full of unusable guards there is no room to add more — so + * replenishment is permanently wedged and every circuit returns + * `AllGuardsDown`. The state persists in `guards.json`, and bootstrap still + * "succeeds" (it reads cached directory data), so none of the init-failure + * self-heal paths ever fire and Tor is stuck across restarts. + * + * A single usable guard is enough to keep building circuits, so we only + * recover at the last resort: when a non-empty guard set has *zero* usable + * guards. A guard is unusable on disk if it has been permanently + * `disabled` or dropped from the consensus (`unlisted_since` set); + * reachability is in-memory only and not persisted, so it can't be checked + * here. Returns true when at least one non-empty selection has no usable + * guard left. + */ + private fun noUsableGuards(): Boolean { + val file = guardsFile() + if (!file.exists()) return false + + return try { + val root = jacksonObjectMapper().readTree(file) + var wedged = false + // Each top-level field is a guard-set selection (e.g. "default"). + root.forEach { selection -> + val guards = selection.get("guards") ?: return@forEach + if (guards.isArray && guards.size() > 0) { + val usable = + guards.count { guard -> + val disabled = guard.get("disabled") + val unlisted = guard.get("unlisted_since") + val isDisabled = disabled != null && !disabled.isNull + val isUnlisted = unlisted != null && !unlisted.isNull + !isDisabled && !isUnlisted + } + if (usable == 0) wedged = true + } + } + wedged + } catch (e: Exception) { + Log.w("TorService") { "Could not inspect guards.json: ${e.message}" } + false + } + } + /** * Clears all Arti persistent data (state + cache). Used as a last resort * when initialization fails, to recover from corrupted state. @@ -122,6 +184,16 @@ class TorService( // fresh network data, preventing stale guards/circuits. clearArtiCache() + // Self-heal the wedged guard sample (see [noUsableGuards]): if + // the persisted sample has no usable guard left, Arti can + // neither build circuits nor replenish, and would return + // AllGuardsDown forever. Wipe the on-disk state so the next + // bootstrap rebuilds a fresh guard sample. + if (noUsableGuards()) { + Log.w("TorService") { "No usable Arti guards left on disk — wiping state to rebuild the guard sample" } + clearAllArtiData() + } + val dataDir = artiDataDir().absolutePath Log.d("TorService") { "Initializing Arti with data dir: $dataDir" } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0d63f89d64..5845409944 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -274,6 +274,13 @@ Generate a new key Loading feed Loading account + encrypted + legacy + Looking for the original message… + + Couldn\'t find this message + + Searched every relay · tap to see "Error loading replies: " Try again No notifications yet. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt new file mode 100644 index 0000000000..b297dabc9f --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RelayLoadingCursorsTest { + private val relayA = RelayUrlNormalizer.normalizeOrNull("wss://a.relay")!! + private val relayB = RelayUrlNormalizer.normalizeOrNull("wss://b.relay")!! + private val start = 1_000L + + @Test + fun unarmedRelayIsNotRequestedAndSitsAtTheFloor() { + val cursors = RelayLoadingCursors() + // never advanced, so it carries no REQ + assertEquals(emptyList(), cursors.armedRelays(listOf(relayA))) + // marker sits at the floor until it delivers + assertEquals(start, cursors.reachedUntilFor(relayA, start)) + } + + @Test + fun firstAdvanceRequestsTheFloorThenSubsequentPagesStepBelowReached() { + val cursors = RelayLoadingCursors() + + assertTrue(cursors.advance(relayA, start)) + assertEquals(start, cursors.requestedUntilFor(relayA)) + + // page returns events; oldest seen = 800 + cursors.onEvent(relayA, 900) + cursors.onEvent(relayA, 800) + cursors.onEose(relayA) + assertEquals(800L, cursors.reachedUntilFor(relayA, start)) + // EOSE does NOT move the requested cursor — the relay parks at the same filter + assertEquals(start, cursors.requestedUntilFor(relayA)) + + // next advance steps to reached - 1 + assertTrue(cursors.advance(relayA, start)) + assertEquals(799L, cursors.requestedUntilFor(relayA)) + } + + @Test + fun emptyPageMarksRelayDoneAndBlocksFurtherAdvance() { + val cursors = RelayLoadingCursors() + cursors.advance(relayA, start) + cursors.onEose(relayA) // no events + assertTrue(cursors.isDone(relayA)) + assertFalse(cursors.advance(relayA, start)) + assertEquals(emptyList(), cursors.armedRelays(listOf(relayA))) + } + + @Test + fun aPageThatDoesNotStepOlderEndsTheRelayInsteadOfLooping() { + val cursors = RelayLoadingCursors() + cursors.advance(relayA, start) + cursors.onEvent(relayA, 800) + cursors.onEose(relayA) + assertEquals(800L, cursors.reachedUntilFor(relayA, start)) + + // misbehaving relay: next page echoes an event no older than what we already reached + cursors.advance(relayA, start) // requested = 799 + cursors.onEvent(relayA, 900) // newer than reached(800) — not strictly older + cursors.onEose(relayA) + assertTrue("a non-advancing page should end the relay, not re-loop", cursors.isDone(relayA)) + assertEquals(800L, cursors.reachedUntilFor(relayA, start)) + } + + @Test + fun relaysAreTrackedIndependently() { + val cursors = RelayLoadingCursors() + cursors.advance(relayA, start) + cursors.onEvent(relayA, 500) + cursors.onEose(relayA) + // B never advanced + assertEquals(listOf(relayA), cursors.armedRelays(listOf(relayA, relayB))) + assertEquals(500L, cursors.reachedUntilFor(relayA, start)) + assertEquals(start, cursors.reachedUntilFor(relayB, start)) + // deepest reached across both = A's 500 (B counts as the floor) + assertEquals(500L, cursors.deepestReached(listOf(relayA, relayB), start)) + } + + @Test + fun deepestReachedIsNullWhenNoRelays() { + val cursors = RelayLoadingCursors() + assertEquals(null, cursors.deepestReached(emptyList(), start)) + } + + // ── rewindTo: realign the window after the cache prunes messages out of it ── + + @Test + fun rewindReopensThePrunedBandAndResumesFromItOnNextAdvance() { + val cursors = RelayLoadingCursors() + cursors.floor = start + + // page deep: floor 1000 → reached 200 + cursors.advance(relayA, start) + cursors.onEvent(relayA, 900) + cursors.onEvent(relayA, 200) + cursors.onEose(relayA) + assertEquals(200L, cursors.reachedUntilFor(relayA, start)) + + // prune drops everything older than 700 (newest pruned = 700) + cursors.rewindTo(mapOf(relayA to 700L)) + + // reached pulled up to just above the pruned band, not done, and un-armed (demand-driven) + assertEquals(701L, cursors.reachedUntilFor(relayA, start)) + assertFalse(cursors.isDone(relayA)) + assertEquals(emptyList(), cursors.armedRelays(listOf(relayA))) + + // the next advance resumes at the boundary and re-requests the pruned band (until = 700), + // NOT from the floor (which would re-stream the still-held tail above 700) + assertTrue(cursors.advance(relayA, start)) + assertEquals(700L, cursors.requestedUntilFor(relayA)) + } + + @Test + fun rewindClearsDoneSoAnExhaustedRelayCanReFetch() { + val cursors = RelayLoadingCursors() + cursors.floor = start + + cursors.advance(relayA, start) + cursors.onEvent(relayA, 300) + cursors.onEose(relayA) // reached 300 + cursors.advance(relayA, start) + cursors.onEose(relayA) // empty page → done + assertTrue(cursors.isDone(relayA)) + + cursors.rewindTo(mapOf(relayA to 500L)) + + assertFalse("a pruned relay must be re-fetchable even after it reached the bottom", cursors.isDone(relayA)) + assertEquals(501L, cursors.reachedUntilFor(relayA, start)) + assertTrue(cursors.advance(relayA, start)) + assertEquals(500L, cursors.requestedUntilFor(relayA)) + } + + @Test + fun rewindNeverClimbsAboveTheFloor() { + val cursors = RelayLoadingCursors() + cursors.floor = start + + cursors.advance(relayA, start) + cursors.onEvent(relayA, 300) + cursors.onEose(relayA) // reached 300 + + // a boundary at/above the floor clamps to the floor (history lives strictly below it) + cursors.rewindTo(mapOf(relayA to start)) + assertEquals(start, cursors.reachedUntilFor(relayA, start)) + } + + @Test + fun rewindSkipsRelaysWithoutACursorOrShallowerThanThePrunedBand() { + val cursors = RelayLoadingCursors() + cursors.floor = start + + // A delivered to 200; B never paged + cursors.advance(relayA, start) + cursors.onEvent(relayA, 200) + cursors.onEose(relayA) + + // B has no cursor (never paged) → skipped, no entry minted; A's reach (200) is already shallower + // than a boundary of 150 (target 151), so it needs no rewind either. + cursors.rewindTo(mapOf(relayB to 500L, relayA to 150L)) + + // B: untouched (still at the floor, unarmed) + assertEquals(start, cursors.reachedUntilFor(relayB, start)) + assertEquals(emptyList(), cursors.armedRelays(listOf(relayB))) + // A: unchanged + assertEquals(200L, cursors.reachedUntilFor(relayA, start)) + } + + @Test + fun rewindIsANoOpWhenTheWindowNeverPagedHistory() { + val cursors = RelayLoadingCursors() + // floor is null (never advanced any history page) + cursors.advance(relayA, start) + cursors.onEvent(relayA, 200) + cursors.onEose(relayA) + + cursors.rewindTo(mapOf(relayA to 150L)) + + // unchanged: with no pinned floor there is no history window to realign + assertEquals(200L, cursors.reachedUntilFor(relayA, start)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt new file mode 100644 index 0000000000..d78a1d0d6c --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds + +/** + * Real-time tests for the **idle** backstop: when a relay streams stored events but never sends EOSE, + * the window can't complete on "every relay settled", so the idle timer finishes it once the stream + * goes quiet — but only after every still-pending relay has been *heard from*, so a slow connect (a + * relay that hasn't answered yet) is never mistaken for a stream that has gone quiet. + */ +class WindowLoadTrackerIdleTest { + private val good = NormalizedRelayUrl("wss://vitor.nostr1.com/") + private val streamer = NormalizedRelayUrl("wss://relay.damus.io/") + + @Test + fun idleBackstopCompletesARelayThatStreamsButNeverEoses() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val tracker = WindowLoadTracker(name = "test", idleTimeout = 50.milliseconds) + + tracker.startLoading(scope) + tracker.setExpectedRelays(setOf(good, streamer)) + tracker.onRelaySettled(good) // `good` finishes with an EOSE + // `streamer` keeps delivering stored events but never sends EOSE — so "every relay settled" + // can never complete this window. Its events keep it "heard from" (idle gate satisfied). + tracker.onRelayEvent(streamer) + tracker.onRelayEvent(streamer) + + // The only way out is the idle backstop, once the stream stays quiet for idleTimeout. + withTimeout(3000) { tracker.loading.first { !it } } + scope.cancel() + } + + @Test + fun idleBackstopWaitsUntilEveryPendingRelayHasBeenHeardFrom() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val tracker = WindowLoadTracker(name = "test", idleTimeout = 50.milliseconds) + + tracker.startLoading(scope) + tracker.setExpectedRelays(setOf(good, streamer)) + tracker.onRelaySettled(good) + // `streamer` has NOT been heard from yet (still connecting). The idle gate must hold the window + // open — a connection gap is not a quiet stream. Wait well past idleTimeout AND a watchdog tick. + Thread.sleep(800) + assertTrue("idle must not fire while a pending relay has never been heard from", tracker.loading.value) + + // Once it delivers something (now heard-from) and the stream goes quiet, idle completes it. + tracker.onRelayEvent(streamer) + withTimeout(3000) { tracker.loading.first { !it } } + scope.cancel() + } +} diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 4b47a1e7de..9924ada856 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -55,6 +55,28 @@ User avatar Navigate + + Loading: + Fully loaded: + (fully loaded) + History by relay + Will retry when you reopen this screen + Older %1$s messages + All caught up + Reached the start of your %1$s messages + %1$s · %2$s · loaded since %3$s + %1$s · %2$s + waiting on %1$s + Some relays didn\'t respond + %1$s unreachable · tap to see which + %1$s · history by relay + since %1$s + Dismiss + + %1$d relay + %1$d relays + + replying to diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 43678ca38a..5c9f6cc7b3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -30,9 +30,10 @@ import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.WeakReference import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject -import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -46,6 +47,12 @@ class Chatroom : NotesGatherer { var ownerSentMessage: Boolean = false var newestMessage: Note? = null + // Per-conversation NIP-04 history paging cursors, held here so reopening this room keeps its + // progress and the cursors share the lifetime of the cached messages. The conversation history + // loader binds its (single-active) orchestrator to this. Lazy — most rooms in the rooms list are + // never opened for history paging, so they never allocate it. + val nip04History by lazy { RelayLoadingCursors() } + // Per-instance lock shared by previously @Synchronized methods. private val syncLock = KmpLock() @@ -132,13 +139,17 @@ class Chatroom : NotesGatherer { val sorted = messages.sortedWith(DefaultFeedOrder) val toKeep = - if ((sorted.firstOrNull()?.createdAt() ?: 0L) > TimeUtils.oneWeekAgo()) { - // Recent messages, keep last 100 - sorted.take(100).toSet() + if ((sorted.firstOrNull()?.createdAt() ?: 0L) > DmHistoryTuning.recentBoundary()) { + // Recent conversation, keep its newest N + sorted.take(DmHistoryTuning.recentKeepCount).toSet() } else { - // Old messages, keep the last one. + // Old conversation, keep the last one. sorted.take(1).toSet() - } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent } + } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent && it.event !is WrappedEvent } + // Both DM protocols are pruned by the recency rule above: NIP-04 (PrivateDmEvent) and NIP-17 + // (WrappedEvent rumors — ChatMessageEvent / file headers). Anything else that ever lands in a + // room is kept. The caller realigns the per-relay download window for the dropped messages so + // they can be paged again later (see LocalCache.pruneOldMessages + RelayLoadingCursors.rewindTo). val toRemove = messages.minus(toKeep) messages = toKeep diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt index 3e2d25893e..ccb294281c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.privateChats import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.utils.cache.LargeCache @@ -34,6 +35,13 @@ class ChatroomList( var rooms = LargeCache() private set + // Account-level DM history paging cursors (one scope per account), held here so they share the + // lifetime of the cached messages and are dropped when the cache prunes them. The account-level + // history loaders bind their orchestrator to these. (Per-conversation NIP-04 cursors live on the + // individual [Chatroom] instead.) + val giftWrapHistory = RelayLoadingCursors() + val nip04History = RelayLoadingCursors() + private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom = rooms.getOrCreate(key) { Chatroom() } fun getOrCreatePrivateChatroom(user: User): Chatroom { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/DmHistoryTuning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/DmHistoryTuning.kt new file mode 100644 index 0000000000..b2c1dae11c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/DmHistoryTuning.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.privateChats + +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.concurrent.Volatile + +/** + * One shared boundary for the DM history window, so the three things that must agree on "where the + * live tail ends and paged history begins" actually read the same number: + * - the live-tail subscriptions' `since` floor (e.g. `AccountGiftWrapsEoseManager`), + * - the backward history pager's pinned floor (`BackwardRelayPager`), + * - the memory prune's recent/old split + retention cap (`Chatroom.pruneMessagesToTheLatestOnly`). + * + * Keeping them in one place avoids the live tail and history overlapping (double-loading) or leaving a + * gap when the boundary is tuned. The knobs are plain `@Volatile` vars (overridable once at startup — + * e.g. shrinking the window in a test to exercise pruning + the per-relay download-window realignment + * without needing week-old threads); they are not meant to change mid-session. + */ +object DmHistoryTuning { + /** Seconds below "now" where the live tail ends and paged history begins. Production: one week. */ + @Volatile + var liveTailSeconds: Long = 7L * TimeUtils.ONE_DAY + + /** How many newest messages a still-recent conversation keeps on a prune. Production: 100. */ + @Volatile + var recentKeepCount: Int = 100 + + /** The epoch-seconds boundary `now − [liveTailSeconds]` (recomputed each call against the clock). */ + fun recentBoundary(): Long = TimeUtils.now() - liveTailSeconds +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt index 191d4e82e6..9502c087ad 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt @@ -32,6 +32,8 @@ fun filterGiftWrapsToPubkey( relay: NormalizedRelayUrl, pubkey: HexKey?, since: Long?, + until: Long? = null, + limit: Int? = null, ): List { if (pubkey.isNullOrEmpty()) return emptyList() @@ -42,7 +44,13 @@ fun filterGiftWrapsToPubkey( Filter( kinds = listOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND), tags = mapOf("p" to listOf(pubkey)), + // A gift wrap's outer created_at is randomized up to 2 days before the real + // message time, so widen the lower bound by 2 days to catch wraps for messages + // right at the floor. (The upper bound needs no margin: a slice's `until` is the + // previous slice's un-margined floor, so the 2-day overlap already covers the seam.) since = since?.minus(TimeUtils.twoDays()), + until = until, + limit = limit, ), ), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt new file mode 100644 index 0000000000..e32444f27c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt @@ -0,0 +1,434 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.feeds + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.action_dismiss +import com.vitorpamplona.amethyst.commons.resources.chats_history_all_caught_up +import com.vitorpamplona.amethyst.commons.resources.chats_history_by_relay +import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete +import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete_sub +import com.vitorpamplona.amethyst.commons.resources.chats_history_older +import com.vitorpamplona.amethyst.commons.resources.chats_history_reached_start +import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_since +import com.vitorpamplona.amethyst.commons.resources.chats_history_relays +import com.vitorpamplona.amethyst.commons.resources.chats_history_relays_title +import com.vitorpamplona.amethyst.commons.resources.chats_history_stalled_retry +import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle +import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle_no_date +import com.vitorpamplona.amethyst.commons.resources.chats_history_waiting +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource + +// How long the "All caught up" state lingers before the card collapses away. +private const val ALL_DONE_VISIBLE_MS = 2200L + +/** + * The "older history" status card for a per-relay [BackwardRelayPager]-backed feed, shown at one + * protocol's oldest-loaded boundary (rooms list and conversation). It tells the user exactly what the + * app is reaching for: which protocol, how many relays it is asking, and how far back it has paged. + * When that protocol runs dry it does NOT just vanish — it crossfades to an "All caught up" state, + * holds for a beat, then collapses away. When it stops short because relays stalled it says so and + * stays put (an *incomplete* window — messages may still be out there). + * + * Shared across front ends; pass the platform's locale date formatter as [formatReachDate] so the card + * carries no `java.time` / `NSDateFormatter` dependency. + * + * @param protocolName human label woven into sentences, e.g. "encrypted" / "legacy". + * @param protocolTag short technical tag for the subtitle, e.g. "NIP-17" / "NIP-04". + * @param reachedBack epoch seconds of the oldest point reached so far (the deepest `until` cursor). + * @param relayProgress per-relay reach (where each relay's window is, done/stalled). Tapping the card + * opens a popup listing them; pass empty to make the card non-interactive. + * @param formatReachDate formats an epoch-seconds reach point to a short label (e.g. "Jun 2026"). + */ +@Composable +fun DmHistoryLoadingCard( + protocolName: String, + protocolTag: String, + loading: Boolean, + exhausted: Boolean, + relayCount: Int, + stalledCount: Int, + reachedBack: Long?, + relayProgress: Map = emptyMap(), + formatReachDate: (epochSeconds: Long) -> String, + modifier: Modifier = Modifier, +) { + // Exhausted ("nothing more reachable right now") splits two ways and must NOT read the same: + // - caughtUp: every relay genuinely bottomed out (empty page). This is the real "all caught up". + // - incomplete: we stopped only because some relays are stalled (auth-walled / offline / silent), + // so messages may still be out there. It must say so, stay put, and let the user tap to see which. + val caughtUp = exhausted && stalledCount <= 0 + val incomplete = exhausted && stalledCount > 0 + + // Relays that still have older history to pull: not done and not stalled. Unlike [relayCount] (only + // those fetching a page *right now*), this also counts relays that returned a page and PARKED — the + // `⋯` paused state — so the subtitle doesn't read as a bare tag while the tap-popup lists N relays. + val reaching = remember(relayProgress) { relayProgress.values.count { !it.done && !it.stalled } } + + // Only the genuine caught-up state lingers then collapses; an incomplete window stays so it can be acted on. + var collapsed by remember { mutableStateOf(false) } + LaunchedEffect(caughtUp) { + collapsed = + if (caughtUp) { + delay(ALL_DONE_VISIBLE_MS) + true + } else { + false + } + } + + var showRelays by remember { mutableStateOf(false) } + if (showRelays) { + DmHistoryRelayDialog(protocolTag, relayProgress, formatReachDate) { showRelays = false } + } + + AnimatedVisibility( + visible = !collapsed, + modifier = modifier, + enter = fadeIn(), + exit = shrinkVertically(tween(400)) + fadeOut(tween(250)), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .then(if (relayProgress.isNotEmpty()) Modifier.clickable { showRelays = true } else Modifier), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + tonalElevation = 2.dp, + ) { + val phase = + when { + caughtUp -> HistoryPhase.CaughtUp + incomplete -> HistoryPhase.Incomplete + else -> HistoryPhase.Loading + } + Crossfade(targetState = phase, animationSpec = tween(500), label = "dmHistoryState") { state -> + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { + when (state) { + HistoryPhase.CaughtUp -> + Text( + "✓", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + HistoryPhase.Incomplete -> + // Same glyph the per-relay dialog uses for a stalled relay, same error colour — + // signals "stopped early, some relays didn't answer", not "done". + Text( + "…", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + ) + HistoryPhase.Loading -> + if (loading) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + // Paused: not caught up, but not actively loading (the auto-fill stopped short + // of exhaustion, or we're between pages). Show a static "more" glyph so the + // icon slot is never blank — resumes on scroll. + Text( + "⋯", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text( + text = + when (state) { + HistoryPhase.CaughtUp -> stringResource(Res.string.chats_history_all_caught_up) + HistoryPhase.Incomplete -> stringResource(Res.string.chats_history_incomplete) + HistoryPhase.Loading -> stringResource(Res.string.chats_history_older, protocolName) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = + when (state) { + HistoryPhase.CaughtUp -> stringResource(Res.string.chats_history_reached_start, protocolName) + HistoryPhase.Incomplete -> incompleteSubtitle(stalledCount) + HistoryPhase.Loading -> historySubtitle(protocolTag, reaching, stalledCount, reachedBack, formatReachDate) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +/** The three terminal-vs-loading faces of the history card: still loading/paused, genuinely caught up, or + * stopped early because relays stalled. Kept distinct so an incomplete window never reads as "all caught up". */ +private enum class HistoryPhase { Loading, CaughtUp, Incomplete } + +/** Subtitle for the "stopped early" state: how many relays we couldn't reach, with a hint to tap for the list. + * Shared with the reply placeholder so both read identically. */ +@Composable +fun incompleteSubtitle(stalledCount: Int): String = + stringResource( + Res.string.chats_history_incomplete_sub, + pluralStringResource(Res.plurals.chats_history_relays, stalledCount, stalledCount), + ) + +@Composable +fun historySubtitle( + protocolTag: String, + relayCount: Int, + stalledCount: Int, + reachedBack: Long?, + formatReachDate: (epochSeconds: Long) -> String, +): String { + val backLabel = remember(reachedBack) { reachedBack?.let(formatReachDate) } + // Middle segment: relays still working on it — fetching a page OR parked with more to pull ("N relays") + // — or, when none are reaching but some can't be reached, what we're waiting on ("waiting on N relays"). + // With neither (every relay done), just the tag. [relayCount] here is the reaching count, not in-flight. + val middle = + when { + relayCount > 0 -> pluralStringResource(Res.plurals.chats_history_relays, relayCount, relayCount) + stalledCount > 0 -> + stringResource( + Res.string.chats_history_waiting, + pluralStringResource(Res.plurals.chats_history_relays, stalledCount, stalledCount), + ) + else -> return protocolTag + } + return if (backLabel != null) { + stringResource(Res.string.chats_history_subtitle, protocolTag, middle, backLabel) + } else { + stringResource(Res.string.chats_history_subtitle_no_date, protocolTag, middle) + } +} + +/** + * Popup shown when the history card is tapped: one row per relay with its state glyph (✓ done, … stalled, + * ↓ still reaching) and how far back it has paged ("since "), deepest-reaching first. A stalled relay + * also gets a one-line hint that it retries when the screen is reopened. + */ +@Composable +fun DmHistoryRelayDialog( + protocolTag: String, + relayProgress: Map, + formatReachDate: (epochSeconds: Long) -> String, + onDismiss: () -> Unit, +) { + val rows = remember(relayProgress) { relayProgress.entries.sortedBy { it.value.reachedUntil } } + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } + }, + title = { Text(stringResource(Res.string.chats_history_relays_title, protocolTag)) }, + text = { + Column( + Modifier + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + ) { + rows.forEach { (relay, p) -> + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = relayStateGlyph(p), + color = relayStateColor(p), + fontWeight = FontWeight.Bold, + modifier = Modifier.width(22.dp), + ) + Text( + text = relayShortName(relay), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(Res.string.chats_history_relay_since, formatReachDate(p.reachedUntil)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + if (p.stalled) StalledRetryHint() + } + } + } + }, + ) +} + +/** The "stopped early, retries on reopen" caption shown under a stalled relay in the per-relay popups. + * Retry is demand-driven (no timer): reopening the screen re-binds and clears the stalled set, which + * retries it — so that's what we tell the user rather than a countdown we can't honour. */ +@Composable +private fun StalledRetryHint() { + Text( + text = stringResource(Res.string.chats_history_stalled_retry), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 22.dp, top = 2.dp), + ) +} + +private fun relayStateGlyph(p: RelayPagingProgress) = + when { + p.done -> "✓" + p.stalled -> "…" + else -> "↓" + } + +@Composable +private fun relayStateColor(p: RelayPagingProgress): Color = + when { + p.done -> MaterialTheme.colorScheme.primary + p.stalled -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .substringAfter("://") + .trimEnd('/') + .substringBefore('/') + +/** + * Popup shown when an in-stream "Loading" marker is tapped: the relays whose window sits at that point + * in the stream, each with its protocol tag, state glyph (✓ done · … stalled · ↓ reaching) and how far + * back it has paged — so the otherwise-terse `Loading: ↓ N` divider stops being a dead end and its + * meaning is explorable. A stalled relay also gets the retry-on-reopen hint. Deepest-reaching first. + */ +@Composable +fun RelayReachDetailDialog( + cursors: List, + formatReachDate: (epochSeconds: Long) -> String, + onDismiss: () -> Unit, +) { + val rows = remember(cursors) { cursors.sortedBy { it.reachedUntil } } + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } + }, + title = { Text(stringResource(Res.string.chats_history_by_relay)) }, + text = { + Column( + Modifier + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + ) { + rows.forEach { c -> + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = reachGlyph(c.state), + color = reachColor(c.state), + fontWeight = FontWeight.Bold, + modifier = Modifier.width(22.dp), + ) + if (c.protocol.isNotEmpty()) { + Text( + text = c.protocol, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + Spacer(Modifier.width(6.dp)) + } + Text( + text = c.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(Res.string.chats_history_relay_since, formatReachDate(c.reachedUntil)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + if (c.state == RelayReachState.STALLED) StalledRetryHint() + } + } + } + }, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt new file mode 100644 index 0000000000..6e25e26c2c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.feeds + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.chats_history_fully_loaded +import com.vitorpamplona.amethyst.commons.resources.chats_history_fully_loaded_label +import com.vitorpamplona.amethyst.commons.resources.chats_history_loading_label +import com.vitorpamplona.amethyst.commons.resources.chats_history_relays +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource + +// A relay-reach divider is hair-thin; inlined here so the shared component carries no app-theme dep. +private val DividerThickness = 0.25.dp + +/** + * True when [reachedUntil] falls in the gap between a newer message (at [newerCreatedAt]) and its + * next-older neighbour (at [olderCreatedAt], null past the oldest end): the newer side is strictly + * newer than the cursor and the older side is at or below it (or absent). This single predicate both + * places the marker ([RelayReachMarkers]) and decides when its paging sentinel is on screen + * ([RelayReachSentinels]), so the two can never disagree about which gap a cursor lives in. + */ +internal fun reachedFallsInGap( + reachedUntil: Long, + newerCreatedAt: Long?, + olderCreatedAt: Long?, +): Boolean = + newerCreatedAt != null && + newerCreatedAt > reachedUntil && + (olderCreatedAt == null || olderCreatedAt <= reachedUntil) + +/** How far one relay has paged into a feed's history, for an in-stream progress marker. */ +enum class RelayReachState { + // Still paging older — its marker slides down (older) as it advances. + REACHING, + + // Accepted but not answering right now (auth CLOSE / unreachable / slow); kept open, still trying. + STALLED, + + // Hit an empty page: nothing older on this relay, it has reached the bottom of its window. + DONE, +} + +/** One relay's marker entry within a gap. */ +data class RelayReach( + val name: String, + val state: RelayReachState, +) + +/** + * One relay's window-limit: places a marker and carries the [advance] that pulls that relay's next, + * older page. The marker sits at [reachedUntil] (the oldest point the relay has paged to); + * [RelayReachMarkers] draws it and [RelayReachSentinels] fires [advance] while it is on + * screen. + * + * @param key stable identity (protocol tag + relay url) so the sentinel survives list reorders. + */ +data class RelayReachCursor( + val key: String, + val name: String, + val reachedUntil: Long, + val state: RelayReachState, + // Short protocol tag (e.g. "NIP-17" / "NIP-04") shown in the tap-through detail popup, so a marker + // that mixes protocols in one gap isn't ambiguous. Empty when the feed has a single (implicit) kind. + val protocol: String = "", + val advance: () -> Unit, +) + +/** + * Drives demand-driven paging for every limit, **hoisted above the list** so its identity does not ride + * on which row currently hosts the marker. Each non-done limit gets one stable effect (keyed by + * [RelayReachCursor.key]) that watches the [listState] and pulls that relay's next page when its marker + * is on screen. + * + * Why hoisted: the marker for a limit lives in exactly one gap (between the two rows straddling its + * reached cursor). Placing the sentinel *inside* that row made its effect's identity ride the hosting + * row — so any feed reorder (a live message, or a slow relay dribbling a history page) moved the gap to a + * different row, tore the effect down and recreated it, and re-fired `advance()` on a static screen. + * That re-armed stalled/auth relays into a silence-watchdog storm and could walk a delivering relay + * back a window with no scroll. Hoisting the effect and driving it off **viewport visibility** instead + * of composition presence removes that coupling. + * + * Fires `advance()` when (and only when) the marker's gap is among the currently visible rows AND either + * it just scrolled into view OR its reached cursor moved (a page landed — keep paging while visible). + * A reorder that keeps the marker on the same side of the fold changes neither, so it no longer re-fires. + * A done relay drives nothing. + * + * @param createdAtAt createdAt of the list item at an index (null past the ends / for non-message rows), + * so the visible-gap test mirrors [RelayReachMarkers]'s placement against only the on-screen rows. + */ +@Composable +fun RelayReachSentinels( + limits: List, + listState: LazyListState, + createdAtAt: (index: Int) -> Long?, +) { + limits.forEach { lim -> + if (lim.state == RelayReachState.DONE) return@forEach + key(lim.key) { + val reached = rememberUpdatedState(lim.reachedUntil) + val advance = rememberUpdatedState(lim.advance) + val getAt = rememberUpdatedState(createdAtAt) + LaunchedEffect(Unit) { + snapshotFlow { + val r = reached.value + val at = getAt.value + // Visible if any on-screen row is the "newer" side of the gap holding this cursor — + // the same predicate RelayReachMarkers uses to place the marker, but over the + // visible rows only. + val onScreen = + listState.layoutInfo.visibleItemsInfo.any { info -> + reachedFallsInGap(r, at(info.index), at(info.index + 1)) + } + // Pair so distinctUntilChanged also lets a landed page (r moved) re-fire while visible, + // not just the off→on-screen transition. + onScreen to r + }.distinctUntilChanged() + .collect { (onScreen, r) -> + if (onScreen) { + // One line per sentinel fire — a re-fire LOOP would show the same key firing + // over and over (and whether its reached cursor is drifting). + Log.d("DMPagination") { "marker fire ${lim.key} reachedUntil=$r" } + advance.value() + } + } + } + } + } +} + +/** + * Renders the window-limit markers for the relays whose limit falls in the gap between a newer message + * (at [newerCreatedAt]) and its next-older neighbour (at [olderCreatedAt], null at the oldest end). Pure + * UI: the load driving lives in [RelayReachSentinels], so this can be (re)placed freely per row on + * every feed reorder without triggering any paging. + * + * A [DONE][RelayReachState.DONE] relay has no incompleteness frontier — it has loaded everything it has — + * so it does NOT mark its own history bottom mid-stream (which would read like a false "incomplete below + * here" line). Instead every done relay sinks to the **oldest-end gap** ([olderCreatedAt] null), where it + * renders as one "fully loaded" marker. Only [REACHING][RelayReachState.REACHING] / + * [STALLED][RelayReachState.STALLED] relays — the genuine "below here may still be incomplete" frontiers — + * are placed at their reached cursor. + */ +@Composable +fun RelayReachMarkers( + limits: List, + newerCreatedAt: Long?, + olderCreatedAt: Long?, + // Tapped with the relays in this gap, so the caller can open a detail popup (which relays, how far + // back, per protocol). Null leaves the marker a passive, non-interactive divider. + onShowDetail: ((List) -> Unit)? = null, +) { + val here = + remember(limits, newerCreatedAt, olderCreatedAt) { + limits.filter { + if (it.state == RelayReachState.DONE) { + // Fully loaded → sink to the oldest end rather than mark a frontier it doesn't have. + newerCreatedAt != null && olderCreatedAt == null + } else { + reachedFallsInGap(it.reachedUntil, newerCreatedAt, olderCreatedAt) + } + } + } + if (here.isEmpty()) return + + RelayReachMarker(here.map { RelayReach(it.name, it.state) }, onClick = onShowDetail?.let { cb -> { cb(here) } }) +} + +/** + * A thin divider drawn between two messages marking the point one or more relays have paged down to. + * As a relay loads older history its reached cursor drops, so the caller places this marker further + * down (older) in the stream — relays that race ahead leave their marker deep while slower relays' + * markers trail higher up, converging as they catch up. + * + * The line is always captioned so it's never a bare glyph cluster: the live frontiers + * ([REACHING][RelayReachState.REACHING] / [STALLED][RelayReachState.STALLED]) read "Loading:"; the + * oldest-end pile of [DONE][RelayReachState.DONE] relays reads "Fully loaded:". Each state then renders + * one compact label: the host name(s) when one — or two short-named — relays sit at that state (the usual + * converged case, where each relay rests at its own depth), or just a count when several pile up at the + * same depth (e.g. all nine clustered at the oldest-end floor) so the line can't grow into an unreadable + * comma list. In the rare mixed line (an active frontier sharing the oldest-end gap with done relays) the + * caption is "Loading:", so the done chip is suffixed "(fully loaded)" to keep its meaning clear. Either + * way the whole marker is tappable for the full per-relay breakdown. Reads e.g. "Loading: ↓ nostr.wine", + * "Loading: ↓ 8 relays" or "Fully loaded: ✓ 8 relays". + */ +@Composable +private fun RelayReachMarker( + entries: List, + onClick: (() -> Unit)? = null, +) { + if (entries.isEmpty()) return + + val hasActiveFrontier = entries.any { it.state != RelayReachState.DONE } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(5.dp).then(if (onClick != null) Modifier.clickable { onClick() } else Modifier), + ) { + HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness) + // Always caption the line so it's never a bare glyph cluster: live frontiers are "Loading:"; the + // oldest-end pile of only-done relays is "Fully loaded:". + Text( + text = + stringResource( + if (hasActiveFrontier) Res.string.chats_history_loading_label else Res.string.chats_history_fully_loaded_label, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + // Present states in enum order. Written with an explicit `sortedBy` + `entry.key`/`entry.value` + // (not `toSortedMap(compareBy { it.ordinal })` + a destructured `(state, list)`) because + // Kotlin/Native's Compose compiler can't infer those inside this inline @Composable lambda + // (commons iOS). + entries + .groupBy { it.state } + .entries + .sortedBy { it.key.ordinal } + .forEachIndexed { index, entry -> + val state = entry.key + val list = entry.value + if (index > 0) { + Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp) + } + // Spell out 1–2 short host names; otherwise "N relays" for every state, so a count chip + // always reads as a sentence ("↓ 8 relays", "✓ 8 relays") rather than a bare number. Only a + // mixed line (caption "Loading:") needs the done chip tagged "(fully loaded)" — a pure-done + // line already says so in its "Fully loaded:" caption. + val names = list.map { it.name } + val label = reachInlineNames(names) ?: pluralStringResource(Res.plurals.chats_history_relays, names.size, names.size) + val chip = reachGlyph(state) + " " + label + Text( + text = if (state == RelayReachState.DONE && hasActiveFrontier) chip + " " + stringResource(Res.string.chats_history_fully_loaded) else chip, + color = reachColor(state), + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness) + } +} + +// Host names short enough to spell out inline on the single-line divider instead of collapsing to a +// bare count: a name up to [INLINE_NAME_MAX] when it's the lone relay of its state, or two names each +// up to [INLINE_TWO_NAMES_MAX] when a pair shares it. Longer hosts, or 3+ relays at one state, return +// null so the caller renders a count instead and the line can't grow unbounded — the tap-through dialog +// always lists them all. +private const val INLINE_NAME_MAX = 16 +private const val INLINE_TWO_NAMES_MAX = 12 + +internal fun reachInlineNames(names: List): String? = + when { + names.size == 1 && names[0].length <= INLINE_NAME_MAX -> names[0] + names.size == 2 && names.all { it.length <= INLINE_TWO_NAMES_MAX } -> names.joinToString(", ") + else -> null + } + +internal fun reachGlyph(state: RelayReachState) = + when (state) { + RelayReachState.REACHING -> "↓" + RelayReachState.STALLED -> "…" + RelayReachState.DONE -> "✓" + } + +@Composable +internal fun reachColor(state: RelayReachState): Color = + when (state) { + RelayReachState.REACHING -> MaterialTheme.colorScheme.onSurfaceVariant + RelayReachState.STALLED -> MaterialTheme.colorScheme.error + RelayReachState.DONE -> MaterialTheme.colorScheme.primary + } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt new file mode 100644 index 0000000000..596677410d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.feeds + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The gap predicate places the per-relay reach marker AND gates its paging sentinel, so an off-by-one + * here would either render a marker in the wrong gap or fire (or never fire) paging. The boundaries are + * deliberately asymmetric — newer side strictly `>`, older side `<=` — so each edge is pinned here. + */ +class RelayReachMarkerTest { + @Test + fun cursorStrictlyBetweenTwoMessagesIsInTheGap() { + // gap is (older=80, newer=100]; a cursor reached down to 90 sits in it. + assertTrue(reachedFallsInGap(reachedUntil = 90, newerCreatedAt = 100, olderCreatedAt = 80)) + } + + @Test + fun cursorAtTheOldestEndWithNoOlderNeighbourIsInTheGap() { + // Past the oldest loaded row (olderCreatedAt null): any cursor below the last message sits here. + assertTrue(reachedFallsInGap(reachedUntil = 50, newerCreatedAt = 100, olderCreatedAt = null)) + } + + @Test + fun noNewerRowMeansNotInThisGap() { + // newerCreatedAt null = nothing on the newer side (e.g. a non-message row) → never placed here. + assertFalse(reachedFallsInGap(reachedUntil = 50, newerCreatedAt = null, olderCreatedAt = 20)) + } + + @Test + fun newerSideIsStrictlyNewer_equalDoesNotCount() { + // The marker belongs in the gap *below* the message it reached, not at the message itself. + assertFalse(reachedFallsInGap(reachedUntil = 100, newerCreatedAt = 100, olderCreatedAt = 50)) + } + + @Test + fun olderSideIsInclusive_equalCounts() { + // older == reached: the cursor sits exactly at the older message → still this gap (`<=`). + assertTrue(reachedFallsInGap(reachedUntil = 80, newerCreatedAt = 100, olderCreatedAt = 80)) + } + + @Test + fun gapWhoseOlderNeighbourIsStillNewerThanTheCursorIsNotIt() { + // older=90 is newer than the cursor (70): the cursor lives in a deeper gap, not this one. + assertFalse(reachedFallsInGap(reachedUntil = 70, newerCreatedAt = 100, olderCreatedAt = 90)) + } + + @Test + fun cursorNewerThanTheNewerRowIsNotInThisGap() { + assertFalse(reachedFallsInGap(reachedUntil = 150, newerCreatedAt = 100, olderCreatedAt = 80)) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt new file mode 100644 index 0000000000..a38dc61050 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.paging + +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * Atomic snapshot of a [BackwardRelayPager]'s display state. Every field is recomputed together in one + * pass ([BackwardRelayPager.status]'s producer), so a consumer collects ONE flow and never sees a torn + * mix (e.g. an updated [relayCount] against a still-stale [relayProgress]) or pays for several separate + * recompositions per page settle. [BackwardRelayPager.loadingMore] is deliberately NOT folded in here: it + * is debounced on its own timer in the load tracker, decoupled from this recompute. + */ +data class PagingStatus( + // Nothing more reachable right now: every relay is done or stalled. See the pager doc — not "caught up". + val exhausted: Boolean = false, + // Relays currently fetching a page (for an "asking N relays" status line). + val relayCount: Int = 0, + // Not-done relays that can't be reached right now (auth CLOSE / unreachable / silent). + val stalledCount: Int = 0, + // Oldest `createdAt` reached across all relays (the deepest cursor), or null before any delivery. + val reachedBack: Long? = null, + // Per-relay window position (reached / done / stalled) — what a caller's per-relay progress UI renders. + val relayProgress: Map = emptyMap(), +) + +/** + * Reusable **per-relay backward pagination** engine: pages a set of relays back through history, + * **one page at a time, per relay, on demand**, by `until`+`limit` ([RelayLoadingCursors]) — with each + * relay advancing independently the moment *it* settles, never paced by the slowest one. + * + * This is the **single-active orchestrator** around the paging state. The state itself (the per-relay + * [RelayLoadingCursors]) does NOT live here — the caller holds it on whatever object owns the scope, so + * its lifetime matches that object. One orchestrator drives whichever scope is currently bound; calling + * [bind] repoints it at that scope's cursors. This is safe as long as the caller only advances the bound + * scope (e.g. the one the user is viewing), so a backgrounded scope produces no callbacks to mis-route. + * + * What it owns (all transient, recomputed on each [bind]): the in-flight + silence tracking + * ([PerRelayLoadTracker]), the stalled-relay set, and the display flows — one atomic [status] snapshot + * ([PagingStatus]) plus the separately-debounced [loadingMore]. The persistent cursors and the pinned + * history floor live on the bound [RelayLoadingCursors]. + * + * What it does NOT own (the caller supplies these — they are protocol- and framework-specific): + * - **Building the actual REQ filters.** The caller reads [armedRelays] + [requestedUntilFor] and + * assembles its own `RelayBasedFilter`s (the kinds / authors / `#p` tags differ per query). + * - **The subscription lifecycle.** The caller wires its `INostrClient` subscription and forwards + * relay callbacks here via [onEvent] / [onEose] / [onClosed] / [onCannotConnect], then re-issues + * its filter after [advance] / [advanceAll] return true. + * - **Which scope's cursors + which relays.** Supplied together by [bind]. + * + * ### Done vs stalled (read [exhausted] with care) + * A relay is **done** once it answers an empty page (gap-proof: nothing older). A relay that won't + * answer right now — auth CLOSE, unreachable, or silent past the tracker's window — is flagged + * **stalled** but kept (its subscription stays open; re-[advance] retries it). [exhausted] flips true + * once every relay is *done or stalled* — "nothing more reachable right now", which is NOT the same as + * "fully caught up". Callers that render a terminal state should split on [stalledCount]: `exhausted && + * stalledCount == 0` is genuinely caught up; `exhausted && stalledCount > 0` stopped early and may be + * missing messages. + * + * Not internally synchronized beyond the primitives it composes; intended to be driven from one owning + * scope with relay callbacks serialized per relay (as the relay IO layer delivers them). + */ +class BackwardRelayPager( + // Short label for the DMPagination logs (e.g. "giftwrap.history", "convo.nip04.history"). + private val name: String, + // Asked of every relay per page; large on purpose (a whole band in one page), and caps per-request + // volume. A relay returning fewer is its own cap, NOT exhaustion — only an empty page ends a relay. + val pageLimit: Int = DEFAULT_PAGE_LIMIT, + // How far below "now" the history floor sits — paging starts here and walks backward. Defaults to + // the shared live-tail boundary ([DmHistoryTuning]): everything newer is the always-on tail's job. + private val liveTailSeconds: Long = DmHistoryTuning.liveTailSeconds, +) { + private val loadTracker = PerRelayLoadTracker(name, onSilenced = ::onSilenced) + + // The active scope, set by [bind]: its persistent per-relay cursors (which live on the owning domain + // object) and the lookup for the relay set it fans out to. + @Volatile + private var cursors: RelayLoadingCursors? = null + + @Volatile + private var relaysFor: () -> Collection? = { null } + + // Relays not advancing for the active scope (auth CLOSE / unreachable / silent). Transient: cleared + // and recomputed on each [bind]; a stalled relay is kept (its sub stays open) and retried on advance. + private val stalledRelays = ConcurrentHashMap.newKeySet() + + /** + * True while any relay is mid-page. Starts false (an idle engine isn't "loading"). Kept apart from + * [status] on purpose: the load tracker debounces this flow's falling edge on its own timer, decoupled + * from the [publishStatus] recompute, so folding it into the snapshot would miss that delayed flip. + */ + val loadingMore: StateFlow = loadTracker.loading + + private val _status = MutableStateFlow(PagingStatus()) + + /** One atomic snapshot of the display state (exhausted / counts / reached / per-relay progress), all + * recomputed together in [publishStatus] so consumers collect ONE flow and never see a torn mix. */ + val status: StateFlow = _status.asStateFlow() + + // The session-pinned floor for the active scope — kept on its cursors so it persists with the scope + // and does not drift forward on recompute (which would re-trigger an undelivered relay's loader). + private fun floor(): Long { + val c = cursors ?: return TimeUtils.now() - liveTailSeconds + return c.floor ?: (TimeUtils.now() - liveTailSeconds).also { c.floor = it } + } + + /** + * Repoints to a scope (call on subscribe / when the active scope changes): its persistent + * [scopeCursors] (held on the caller's scope object), the [scope] for the silence watchdog, and the + * [relaysForScope] lookup. Resets the transient orchestration (in-flight, stalled) and recomputes + * the display flows from the bound cursors — so a previously-paged scope restores its progress + * instead of restarting. + */ + fun bind( + scopeCursors: RelayLoadingCursors, + scope: CoroutineScope, + relaysForScope: () -> Collection?, + ) { + cursors = scopeCursors + relaysFor = relaysForScope + loadTracker.bind(scope) + loadTracker.reset() + stalledRelays.clear() + publishStatus() + } + + /** + * Whether [c] is the currently-bound scope's cursor object. The orchestrator is single-active: it can + * only correctly process callbacks for the bound scope. A caller whose subscription may still be alive + * for a *just-backgrounded* scope (navigation overlap, a second pane) must gate its forwarded callbacks + * on this — otherwise a late EOSE from scope A would move scope B's cursors. The cursor object is the + * scope identity (one per `Chatroom`/`ChatroomList`), so reference identity is the check. + */ + fun isBoundTo(c: RelayLoadingCursors): Boolean = c === cursors + + // --- Filter building support: the caller assembles the actual REQ from these. --- + + /** Relays of the active scope that have been advanced (armed) and aren't done — i.e. carry a REQ. */ + fun armedRelays(relays: Collection): List = cursors?.armedRelays(relays) ?: emptyList() + + /** The `until` [relay]'s next page should carry (null if it isn't armed / no scope bound). */ + fun requestedUntilFor(relay: NormalizedRelayUrl): Long? = cursors?.requestedUntilFor(relay) + + // --- Demand-driven advance (the caller re-issues its filter when these return true). --- + + /** Steps a single [relay] to its next, older page. @return true if it actually advanced. */ + fun advance(relay: NormalizedRelayUrl): Boolean { + if (!arm(relay)) return false + publishStatus() + return true + } + + /** Steps every not-done, not-in-flight relay of the active scope one page. For a scope too small to scroll. */ + fun advanceAll(): Boolean { + val relays = relaysFor() ?: return false + var any = false + relays.forEach { if (arm(it)) any = true } + if (any) publishStatus() + return any + } + + // Moves one relay's cursor to its next page and marks it in-flight. Returns false if it can't advance + // (no scope bound, unknown relay, already fetching, or already done). Caller batches the recompute. + private fun arm(relay: NormalizedRelayUrl): Boolean { + val c = cursors ?: return false + val relays = relaysFor() ?: return false + if (relay !in relays) return false + if (loadTracker.isInFlight(relay)) return false + if (!c.advance(relay, floor())) return false + stalledRelays.remove(relay) + loadTracker.onAdvance(relay) + return true + } + + // --- Subscription callbacks: the owner forwards these from its SubscriptionListener. --- + + /** Records one delivered event for [relay] (a sign of life + a page tally entry). */ + fun onEvent( + relay: NormalizedRelayUrl, + createdAt: Long, + ) { + loadTracker.onActivity() + cursors?.onEvent(relay, createdAt) + stalledRelays.remove(relay) + } + + /** Finalizes [relay]'s page on EOSE. @return true if this EOSE is the one that marked it done. */ + fun onEose(relay: NormalizedRelayUrl): Boolean { + val c = cursors ?: return false + stalledRelays.remove(relay) + c.onEose(relay) + loadTracker.onSettled(relay) + val done = c.isDone(relay) + publishStatus() + return done + } + + /** [relay] rejected the REQ (e.g. auth-required): settle it and flag it stalled (kept, retryable). */ + fun onClosed( + relay: NormalizedRelayUrl, + message: String, + ) { + loadTracker.onSettled(relay) + markStalled(relay, "CLOSED: $message") + publishStatus() + } + + /** [relay] is unreachable right now: settle it and flag it stalled (kept, retryable). */ + fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + ) { + loadTracker.onSettled(relay) + markStalled(relay, "cannot connect: $message") + publishStatus() + } + + // The tracker's silence watchdog fired: the still-pending relays went quiet after their REQ. Flag them + // stalled but kept, so the window can settle instead of hanging on a dead relay. + private fun onSilenced(relays: Set) { + relays.forEach { markStalled(it, "no response (silence timeout)") } + publishStatus() + } + + private fun markStalled( + relay: NormalizedRelayUrl, + reason: String, + ) { + if (stalledRelays.add(relay)) Log.d(TAG) { "[$name] ${relay.url} stalled — $reason (kept, advance to retry)" } + } + + // --- Display-state recompute (one atomic snapshot from the bound cursors). --- + + /** + * Recomputes the whole [status] snapshot from the active scope's cursors and publishes it in one + * emission, so consumers never see a torn mix of fields nor pay for several recompositions per settle. + * + * `exhausted` is computed here too: nothing more is reachable once every relay is done (empty page) or + * stalled (unreachable) — a merely parked relay (more to load, just not advancing) keeps it false. An + * empty / unbound scope leaves `exhausted` at its previous value (mirrors the old recompute's + * early-return), so a transient empty relay set never flips it spuriously. + */ + private fun publishStatus() { + val c = cursors + val relays = relaysFor() ?: emptySet() + val floor = floor() + val prev = _status.value + val exhausted = + if (c == null || relays.isEmpty()) { + prev.exhausted + } else { + relays.none { !c.isDone(it) && it !in stalledRelays } + } + if (exhausted && !prev.exhausted && c != null) { + val done = relays.filter { c.isDone(it) }.map { it.url } + val stuck = relays.filter { it in stalledRelays && !c.isDone(it) }.map { it.url } + Log.d(TAG) { "[$name] window settled (nothing more reachable) — done=$done stalled=$stuck" } + } + _status.value = + PagingStatus( + exhausted = exhausted, + relayCount = loadTracker.count(), + stalledCount = relays.count { it in stalledRelays && c?.isDone(it) != true }, + reachedBack = c?.deepestReached(relays, floor), + relayProgress = + relays.associateWith { relay -> + RelayPagingProgress( + reachedUntil = c?.reachedUntilFor(relay, floor) ?: floor, + done = c?.isDone(relay) ?: false, + stalled = relay in stalledRelays && c?.isDone(relay) != true, + ) + }, + ) + } + + companion object { + private const val TAG = "DMPagination" + + const val DEFAULT_PAGE_LIMIT = 10000 + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/PerRelayLoadTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/PerRelayLoadTracker.kt new file mode 100644 index 0000000000..732b6d1917 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/PerRelayLoadTracker.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.paging + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks which relays currently have a demand-driven history page **in flight**, so a caller can show a + * spinner while any relay is fetching and clear it the moment they've all answered (or parked). Unlike + * [WindowLoadTracker] this has no notion of a "window" or "round" — relays are advanced one page at a + * time, independently, on demand by the caller, so completion is simply "nothing in flight." + * + * A single backstop covers a relay that accepts a REQ and then goes silent (auth-walled / dead): if + * nothing has been heard from ANY in-flight relay for [silenceMs], the still-pending relays are dropped + * from the in-flight set (so the spinner clears) and reported to [onSilenced] so the owner can flag them + * stalled. Relays that answer with CLOSED / cannot-connect are settled directly by the owner and don't + * need the watchdog. + */ +class PerRelayLoadTracker( + private val name: String, + // How long the whole in-flight cohort can go without ANY signal before the still-pending relays are + // dropped + reported stalled. lastActivityMs is global, so any relay delivering keeps it fresh for all + // — this only fires on total dead air. Set well above mobile-over-Tor connect times (which run tens of + // seconds, occasionally past a minute): at 15 s a relay was being flagged "stalled" before its circuit + // even finished connecting. A genuinely dead relay still settles via CLOSED/cannot-connect, not this. + private val silenceMs: Long = 60_000L, + private val onSilenced: (Set) -> Unit = {}, +) { + private val _loading = MutableStateFlow(false) + val loading: StateFlow = _loading.asStateFlow() + + private val inFlight = ConcurrentHashMap.newKeySet() + + @Volatile + private var lastActivityMs = 0L + + @Volatile + private var watchdog: Job? = null + + // Delays dropping the spinner so back-to-back pages don't flicker it off between each one. + @Volatile + private var clearJob: Job? = null + + @Volatile + private var scope: CoroutineScope? = null + + fun bind(scope: CoroutineScope) { + this.scope = scope + } + + fun isInFlight(relay: NormalizedRelayUrl) = inFlight.contains(relay) + + fun count() = inFlight.size + + /** A relay's next page was just requested. Raises the spinner and (re)arms the silence watchdog. */ + @Synchronized + fun onAdvance(relay: NormalizedRelayUrl) { + clearJob?.cancel() // a new page is starting — keep the spinner up, no flicker + clearJob = null + inFlight.add(relay) + lastActivityMs = TimeUtils.nowMillis() + _loading.value = true + ensureWatchdog() + } + + /** A sign of life from a relay (an event). Keeps the silence watchdog from firing. */ + fun onActivity() { + lastActivityMs = TimeUtils.nowMillis() + } + + /** + * A relay answered (EOSE / CLOSED / cannot-connect). Drops it from in-flight. When the last one + * settles, the spinner is dropped after a short linger rather than immediately, so a relay paging + * page-after-page (each page settles, then the caller advances the next) keeps a steady spinner instead + * of flickering it off for the few ms between pages. The linger is cancelled the moment a new page + * starts ([onAdvance]). + */ + @Synchronized + fun onSettled(relay: NormalizedRelayUrl) { + lastActivityMs = TimeUtils.nowMillis() + if (inFlight.remove(relay) && inFlight.isEmpty()) scheduleClear() + } + + private fun scheduleClear() { + clearJob?.cancel() + val s = scope + if (s == null) { + _loading.value = false + return + } + clearJob = + s.launch { + delay(LOADING_LINGER_MS) + synchronized(this@PerRelayLoadTracker) { + if (inFlight.isEmpty()) _loading.value = false + } + } + } + + /** Drops everything (e.g. the bound scope switched). */ + @Synchronized + fun reset() { + inFlight.clear() + clearJob?.cancel() + clearJob = null + _loading.value = false + watchdog?.cancel() + watchdog = null + } + + private fun ensureWatchdog() { + if (watchdog?.isActive == true) return + val s = scope ?: return + watchdog = + s.launch { + while (isActive) { + delay(WATCHDOG_TICK_MS) + val silenced = + synchronized(this@PerRelayLoadTracker) { + if (inFlight.isNotEmpty() && TimeUtils.nowMillis() - lastActivityMs > silenceMs) { + val pending = inFlight.toSet() + inFlight.clear() + _loading.value = false + pending + } else { + emptySet() + } + } + if (silenced.isNotEmpty()) { + Log.d(TAG) { "[$name] silenced (no response ${silenceMs}ms): ${silenced.map { it.url }}" } + onSilenced(silenced) + } + if (inFlight.isEmpty()) break + } + } + } + + companion object { + private const val TAG = "DMPagination" + private const val WATCHDOG_TICK_MS = 1_000L + + // How long to keep the spinner up after the last page settles, to bridge the gap to the next + // back-to-back page so the card doesn't flicker between every page. + private const val LOADING_LINGER_MS = 600L + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/WindowLoadTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/WindowLoadTracker.kt new file mode 100644 index 0000000000..345fe58f18 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/WindowLoadTracker.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.paging + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Tracks when one relay-subscription "window" has finished loading, so a caller (e.g. an auto-fill / + * pagination loop) can wait for the WHOLE response instead of declaring victory on the first EOSE. + * + * A subscription fans a single REQ out to several relays. The first EOSE is a misleading "done" + * signal: a fast but near-empty relay can EOSE in milliseconds while the relay that actually holds + * the data is still connecting, stuck in an auth handshake, or busy streaming thousands of stored + * events. An auto-fill loop driven by the first EOSE — or by a fixed wall-clock timeout — would + * widen the window again mid-stream, before the events were even processed, re-issuing an ever-wider + * REQ that re-downloads the whole history over and over. + * + * Completion is therefore **per-relay terminal-state** based, not wall-clock based. A relay is + * *settled* once it answers with a terminal signal — an EOSE (stored backfill done), a CLOSED (it + * rejected the REQ, e.g. `auth-required`), or a cannot-connect (it is unreachable). The load is done + * when **every targeted relay has settled** ([settled] ⊇ [expected]). This is the only signal that + * survives the real world: relays connect over a wide spread (tens of seconds on mobile), and some + * answer only with CLOSED — a quiet-time heuristic fires in the gap between two relays connecting and + * mistakes a half-loaded window for a finished one, which is exactly how a load reports "1 event" + * when a hundred are still on the way. + * + * Two backstops cover misbehaving relays. If every relay we're still waiting on has at least been + * *heard from* (any event, EOSE, CLOSED, or cannot-connect) but one streamed events without ever + * sending EOSE, an [idleTimeout] of quiet completes the load — the "heard from" gate is what keeps + * this from firing in a connection gap. And an [absoluteCap] is the final ceiling on a window that + * somehow defeats the above. + */ +class WindowLoadTracker( + // Short label for the DMPagination logs (e.g. "giftwrap", "rooms.nip04", "convo.nip04"). + private val name: String = "dm", + private val idleTimeout: Duration = 3.seconds, + private val absoluteCap: Duration = 5.minutes, +) { + private val _loading = MutableStateFlow(true) + val loading: StateFlow = _loading.asStateFlow() + + // Relays the current REQ was sent to. Volatile: written on IO (updateFilter), read on the + // listener threads and the watchdog. + @Volatile + private var expected: Set = emptySet() + + // Relays that have produced any signal at all (event / EOSE / CLOSED / cannot-connect). The idle + // backstop only arms once this covers [expected], so a still-connecting relay can't be skipped. + private val heardFrom = ConcurrentHashMap.newKeySet() + + // Relays that reached a terminal signal (EOSE / CLOSED / cannot-connect). When this covers + // [expected] the stored backfill is complete on every relay and the load is done. + private val settled = ConcurrentHashMap.newKeySet() + + private var watchdog: Job? = null + + // Incremented on every (re)start so a stale watchdog that wakes right as a new load begins + // recognizes it has been superseded and bows out instead of completing the new window. + private var generation = 0 + + // Wall-clock of the last signal for the current window; the idle backstop completes the window + // once this stops advancing for [idleTimeout]. Volatile so the hot per-event path stays lock-free. + @Volatile + private var lastActivityMs = 0L + + /** Begins a fresh window load: clears the per-relay sets, raises [loading], and arms the watchdog. */ + @Synchronized + fun startLoading(scope: CoroutineScope) { + val gen = ++generation + expected = emptySet() + heardFrom.clear() + settled.clear() + lastActivityMs = TimeUtils.nowMillis() + val wasLoading = _loading.value + _loading.value = true + Log.d(TAG) { "[$name] load start" + if (!wasLoading) "" else " (restart)" } + watchdog?.cancel() + watchdog = + scope.launch { + val deadline = TimeUtils.nowMillis() + absoluteCap.inWholeMilliseconds + while (isActive) { + delay(IDLE_CHECK_MS) + if (!tick(gen, TimeUtils.nowMillis(), deadline)) break + } + } + } + + // One watchdog poll. Returns false (stop polling) when this watchdog has been superseded by a + // newer load, the window already finished, or a completion deadline is reached. Synchronized so + // the generation/loading checks and the completion are atomic against startLoading/finish. + @Synchronized + private fun tick( + gen: Int, + now: Long, + deadline: Long, + ): Boolean { + if (gen != generation || !_loading.value) return false + if (expected.isNotEmpty()) { + // Once every relay has reached a terminal signal, nothing more is coming for this round. + if (settled.containsAll(expected)) { + finish("settled") + return false + } + // Idle backstop: every relay we're still waiting on has at least streamed something (so this + // isn't a connection gap) and the stream has gone quiet. Settled relays don't count. + val stillWaiting = expected.filterNot { settled.contains(it) } + if (stillWaiting.all { heardFrom.contains(it) } && now - lastActivityMs >= idleTimeout.inWholeMilliseconds) { + finish("idle") + return false + } + } + if (now >= deadline) { + finish("cap") + return false + } + return true + } + + /** Records which relays the current REQ was sent to. Completes immediately if there are none. */ + @Synchronized + fun setExpectedRelays(relays: Set) { + expected = relays + if (relays.isEmpty()) { + finish("no relays") + } else if (settled.containsAll(relays)) { + finish("all relays") + } + } + + /** A non-terminal sign of life from [relay] (a stored or live event). Keeps the idle timer alive. */ + fun onRelayEvent(relay: NormalizedRelayUrl) { + heardFrom.add(relay) + lastActivityMs = TimeUtils.nowMillis() + } + + /** + * A terminal signal from [relay] — EOSE, CLOSED, or cannot-connect. Once every expected relay has + * settled the stored backfill is complete and the load finishes. + */ + @Synchronized + fun onRelaySettled(relay: NormalizedRelayUrl) { + lastActivityMs = TimeUtils.nowMillis() + heardFrom.add(relay) + settled.add(relay) + if (expected.isNotEmpty() && settled.containsAll(expected)) finish("all relays") + } + + // Idempotent: only the first call after a load actually completes (and logs); later calls no-op. + @Synchronized + private fun finish(reason: String) { + if (!_loading.value) return + watchdog?.cancel() + watchdog = null + Log.d(TAG) { "[$name] load done: $reason" } + _loading.value = false + } + + companion object { + private const val TAG = "DMPagination" + private const val IDLE_CHECK_MS = 500L + } +} + +/** + * Builds the standard [SubscriptionListener] that feeds this tracker. Every event (stored backfill + * included) is a non-terminal sign of life from its relay; an EOSE, CLOSED, or cannot-connect settles + * that relay. [forward] carries the EOSE / live-event signal so the owning EOSE manager can record the + * relay's timestamp (its usual `newEose`). + */ +fun WindowLoadTracker.trackingListener(forward: (NormalizedRelayUrl, List?) -> Unit): SubscriptionListener = + object : SubscriptionListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onRelaySettled(relay) + forward(relay, forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onRelayEvent(relay) + if (isLive) { + forward(relay, forFilters) + } + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onRelaySettled(relay) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + onRelaySettled(relay) + } + } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt new file mode 100644 index 0000000000..01f9543be7 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.paging + +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * State-machine tests for [BackwardRelayPager]: drive its relay callbacks directly (no network) and + * assert the cursor / done / stalled / exhausted bookkeeping — the logic that backed the "All caught up + * while messages missing" and the stalled-vs-done bugs. The relay's own `until`+`limit`+EOSE wire + * behaviour is covered separately against the in-process relay in `UntilLimitPagingRelayTest`. + * + * The pager is the **single-active orchestrator**: its per-relay cursors live on a separate + * [RelayLoadingCursors] (in production, on a `Chatroom` / `ChatroomList`), bound in via [bind]. These tests + * supply their own cursor object so they can rebind a previously-paged scope and assert what persists + * (the cursors) versus what is transient and recomputed (the stalled set, the live flows). + */ +class BackwardRelayPagerTest { + private val r1 = NormalizedRelayUrl("wss://r1.example/") + private val r2 = NormalizedRelayUrl("wss://r2.example/") + private val r3 = NormalizedRelayUrl("wss://r3.example/") + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + @AfterTest + fun tearDown() { + scope.cancel() + } + + // A pager bound to a fresh scope of [relays]; returns both so tests can read the pinned cursor floor. + private fun pagerOf(vararg relays: NormalizedRelayUrl): Pair { + val cursors = RelayLoadingCursors() + val p = BackwardRelayPager("test") + p.bind(cursors, scope) { relays.toList() } + return p to cursors + } + + @Test + fun firstPageRequestsTheFloorAndAnEmptyPageIsCaughtUp() { + val (p, cursors) = pagerOf(r1) + assertFalse(p.status.value.exhausted) + + assertTrue(p.advance(r1)) + // The very first page asks `until = floor` (pinned on the bound cursors). + assertEquals(cursors.floor, p.requestedUntilFor(r1)) + + // Empty page + EOSE → that relay is done; the only relay is done → genuinely caught up. + assertTrue(p.onEose(r1)) + assertTrue( + p.status.value.relayProgress + .getValue(r1) + .done, + ) + assertTrue(p.status.value.exhausted) + assertEquals(0, p.status.value.stalledCount) + } + + @Test + fun nonEmptyPageMovesTheCursorThenBottomsOut() { + val (p, _) = pagerOf(r1) + p.advance(r1) + + // A page of three events; the oldest is 80, so the reached cursor drops to 80 (not done). + p.onEvent(r1, 100) + p.onEvent(r1, 80) + p.onEvent(r1, 90) + assertFalse(p.onEose(r1)) + assertFalse( + p.status.value.relayProgress + .getValue(r1) + .done, + ) + assertEquals(80L, p.status.value.reachedBack) + assertFalse(p.status.value.exhausted) + + // The next page must start strictly below the oldest reached (80 → until 79). + assertTrue(p.advance(r1)) + assertEquals(79L, p.requestedUntilFor(r1)) + + // Empty page now → done → caught up. + assertTrue(p.onEose(r1)) + assertTrue(p.status.value.exhausted) + assertEquals(0, p.status.value.stalledCount) + } + + @Test + fun aStalledRelayMakesExhaustionIncompleteNotCaughtUp() { + val (p, _) = pagerOf(r1, r2) + p.advance(r1) + p.advance(r2) + + // r1 genuinely bottoms out; r2 is still pending, so not exhausted yet. + p.onEose(r1) + assertFalse(p.status.value.exhausted) + + // r2 auth-walls the REQ → stalled (kept, not done). + p.onClosed(r2, "auth-required") + assertTrue( + p.status.value.relayProgress + .getValue(r2) + .stalled, + ) + assertFalse( + p.status.value.relayProgress + .getValue(r2) + .done, + ) + + // Every relay is now done-or-stalled → exhausted, but it is INCOMPLETE: one relay unreachable. + assertTrue(p.status.value.exhausted) + assertEquals(1, p.status.value.stalledCount) + } + + @Test + fun cannotConnectAlsoStalls() { + val (p, _) = pagerOf(r1) + p.advance(r1) + p.onCannotConnect(r1, "offline") + assertTrue( + p.status.value.relayProgress + .getValue(r1) + .stalled, + ) + assertTrue(p.status.value.exhausted) + assertEquals(1, p.status.value.stalledCount) + } + + @Test + fun reAdvancingAStalledRelayClearsTheStallAndUnExhausts() { + val (p, _) = pagerOf(r1) + p.advance(r1) + p.onClosed(r1, "auth-required") + assertTrue(p.status.value.exhausted) + assertEquals(1, p.status.value.stalledCount) + + // Retrying it re-arms the relay: no longer stalled, no longer exhausted. + assertTrue(p.advance(r1)) + assertFalse( + p.status.value.relayProgress + .getValue(r1) + .stalled, + ) + assertFalse(p.status.value.exhausted) + assertEquals(0, p.status.value.stalledCount) + } + + @Test + fun reachedBackIsTheDeepestCursorAcrossRelays() { + val (p, _) = pagerOf(r1, r2) + p.advance(r1) + p.advance(r2) + + p.onEvent(r1, 500) + p.onEose(r1) // r1 reached 500 + + p.onEvent(r2, 300) + p.onEose(r2) // r2 reached 300 + + // Deepest = the oldest point any relay has reached. + assertEquals(300L, p.status.value.reachedBack) + } + + @Test + fun aDoneRelayWillNotAdvanceAgain() { + val (p, _) = pagerOf(r1) + p.advance(r1) + p.onEose(r1) // empty → done + assertFalse(p.advance(r1)) + } + + @Test + fun advanceAllArmsEveryNotDoneRelay() { + val (p, _) = pagerOf(r1, r2, r3) + // r2 already finished; advanceAll should arm only r1 and r3. + p.advance(r2) + p.onEose(r2) + + assertTrue(p.advanceAll()) + assertEquals(setOf(r1, r3), p.armedRelays(listOf(r1, r2, r3)).toSet()) + } + + @Test + fun rebindingRepointsFlowsKeepingDoneCursorsButDroppingTransientStalls() { + val cursorsA = RelayLoadingCursors() + val cursorsB = RelayLoadingCursors() + val p = BackwardRelayPager("test") + + // Scope A: r1 bottoms out (done — a persistent cursor fact); r2 auth-walls (stalled — transient). + p.bind(cursorsA, scope) { listOf(r1, r2) } + p.advance(r1) + p.advance(r2) + p.onEose(r1) + p.onClosed(r2, "auth-required") + assertTrue(p.status.value.exhausted) + assertEquals(1, p.status.value.stalledCount) + + // Bind to a fresh scope B: the flows reflect B's own (empty) state — nothing stalled, and its + // reach sits at B's floor (no history fetched yet — markers start at the live-tail boundary). + p.bind(cursorsB, scope) { listOf(r3) } + assertFalse(p.status.value.exhausted) + assertEquals(0, p.status.value.stalledCount) + assertEquals(cursorsB.floor, p.status.value.reachedBack) + + // Rebind to A: r1 is still DONE (its cursor persisted on cursorsA), but r2's stall is gone — stall + // is transient, so r2 is pending again and A is no longer exhausted (it will retry the auth relay). + p.bind(cursorsA, scope) { listOf(r1, r2) } + assertTrue( + p.status.value.relayProgress + .getValue(r1) + .done, + ) + assertEquals(0, p.status.value.stalledCount) + assertFalse(p.status.value.exhausted) + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt index 88befaaa2c..1acffd1427 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils actual fun platform() = "Android" actual fun currentTimeSeconds() = System.currentTimeMillis() / 1000 + +actual fun currentTimeMillis() = System.currentTimeMillis() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt new file mode 100644 index 0000000000..cb720f48f9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt @@ -0,0 +1,211 @@ +/* + * 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.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlin.concurrent.Volatile + +/** + * Per-relay `until`+`limit` pagination cursors for **one scope** (one account, or one conversation): + * how far back each relay has been *asked* to load, and how far it has actually *delivered*. + * + * Pure paging state, no orchestration — the loading / stall / exhaustion / live status a caller shows + * around paging is a separate concern, layered on top by a driver (in this library, `BackwardRelayPager`). + * It carries no key on purpose: the caller holds one instance per scope on whatever object owns that + * scope, so the cursors share that object's lifetime and the object graph is the partition — not a map + * kept in here. + * + * Why `until`+`limit` and not a time window: a `since`/`until` slice that comes back empty can't tell + * "nothing older here" from "just a quiet gap". `until`+`limit` returns the N newest events older than + * `until`, skipping gaps — so an **empty page + EOSE is the gap-proof stop** ([isDone]). (A short page, + * fewer than the limit, is the relay capping the response, not the bottom.) + * + * Two cursors per relay, kept apart so a relay never pages past what it was asked: + * - [requestedUntilFor] — the `until` its REQ carries; moves only in [advance]. Untouched on EOSE, so a + * finished page just parks (no re-REQ) until advanced again — this is what makes paging demand-driven. + * - reached ([reachedUntilFor]) — the oldest `created_at` it has delivered; moves on EOSE. This is the + * "loaded back to here" point, and the next [advance] starts just below it. + * + * No locks of its own: each relay's callbacks are serialized, the cursor fields are `@Volatile`, and the + * relay map is the thread-safe [LargeCache] (keyed by [NormalizedRelayUrl], which orders consistently + * with `equals`). + */ +class RelayLoadingCursors { + private class RelayCursor { + // The `until` the REQ carries; null until the relay is first advanced. Moves only in advance(). + @Volatile var requestedUntil: Long? = null + + // The oldest created_at this relay has delivered; null until its first non-empty page. Moves on + // EOSE. This is the "loaded back to here" point; the next page starts just below it. + @Volatile var reachedUntil: Long? = null + + // Set once the relay answered an empty page with EOSE: there is nothing older on it. + @Volatile var done: Boolean = false + + // Per-page tallies, reset by [advance]: how many events arrived and the oldest among them. + @Volatile var pageCount: Int = 0 + + @Volatile var pageOldest: Long = Long.MAX_VALUE + } + + private val cursors = LargeCache() + + /** + * The history floor this scope pages down from (e.g. `now − liveTail`, pinned by the owner on first + * advance). Kept here so it persists with the scope and doesn't drift on recompute — a relay that + * hasn't delivered yet reports this floor as its reached point, and a moving floor would make a + * demand-driven loader re-fire on it. + */ + @Volatile + var floor: Long? = null + + private fun cursor(relay: NormalizedRelayUrl) = cursors.getOrCreate(relay) { RelayCursor() } + + /** The `until` [relay]'s REQ currently carries. Only meaningful once it has been [advance]d. */ + fun requestedUntilFor(relay: NormalizedRelayUrl): Long? = cursor(relay).requestedUntil + + /** The oldest point [relay] has reached, or [start] if it hasn't delivered yet. */ + fun reachedUntilFor( + relay: NormalizedRelayUrl, + start: Long, + ): Long = cursor(relay).reachedUntil ?: start + + /** True once [relay] answered an empty page with EOSE — nothing older to ask it for. */ + fun isDone(relay: NormalizedRelayUrl): Boolean = cursor(relay).done + + /** + * Steps [relay] to its next, older page: points its REQ just below the oldest event it has delivered + * (or [start] for its very first page) and clears the page tally. No-op (returns false) if the relay + * has already paged to the bottom ([isDone]). The owner re-issues the relay's REQ after this. + */ + fun advance( + relay: NormalizedRelayUrl, + start: Long, + ): Boolean { + val c = cursor(relay) + if (c.done) return false + val reached = c.reachedUntil + c.requestedUntil = + when { + // Resume just below the oldest event already delivered. Covers both normal page-to-page + // advance and a post-[rewindTo] resume (which un-arms the relay — requestedUntil back to + // null — but keeps the rewound reached point, so the next page picks up at the boundary + // instead of restarting at the floor and re-streaming the still-held tail). + reached != null -> reached - 1 + // Very first page for this relay (nothing delivered, nothing requested yet). + c.requestedUntil == null -> start + // Armed but still mid-page (no EOSE yet) — keep asking from the same top. + else -> start + } + c.pageCount = 0 + c.pageOldest = Long.MAX_VALUE + return true + } + + /** Records one event for [relay] in the current page. */ + fun onEvent( + relay: NormalizedRelayUrl, + createdAt: Long, + ) { + val c = cursor(relay) + c.pageCount++ + if (createdAt < c.pageOldest) c.pageOldest = createdAt + } + + /** + * Finalizes [relay] for the page on its EOSE: an empty page marks it [isDone]; otherwise the reached + * cursor drops to the oldest event the page returned. The requested cursor is left alone so the relay + * parks until [advance] is called again. + */ + fun onEose(relay: NormalizedRelayUrl) { + val c = cursor(relay) + if (c.pageCount == 0) { + c.done = true + } else { + // The reached cursor must move strictly older every page (the next page asks `until = + // reached - 1`). A relay that returns events but none older than we already have — a + // misbehaving relay echoing the same newest events — would otherwise pin the cursor and a + // demand-driven loader would re-request the same window forever. Treat that as the bottom. + val prev = c.reachedUntil + if (prev == null || c.pageOldest < prev) { + c.reachedUntil = c.pageOldest + } else { + c.done = true + } + } + } + + /** + * Realigns the window after the cache prunes messages out of it: for each `relay → newestPrunedUntil` + * entry, rewinds that relay so it no longer claims to hold anything at or below [newestPrunedUntil] + * (the newest cursor-space `created_at` among the messages pruned from that relay — for gift wraps the + * **outer-wrap** time, recovered from the rumor's [host][com.vitorpamplona.quartz.nip59Giftwrap.HostStub]). + * + * Without this, a relay that already paged past the pruned band — or reached `done` — would never + * re-request the dropped messages: its [reachedUntil] still points below them, so the next [advance] + * starts even older and skips the hole entirely. + * + * The rewind pulls [reachedUntil] back up to just above [newestPrunedUntil] (so the next page's + * `until` re-includes it), clears [done] (there *is* older data to re-fetch again), and un-arms the + * relay (requested cursor back to null) so paging stays demand-driven — the dropped band comes back + * only when the on-screen marker advances the relay again, not eagerly on the next re-subscribe. + * + * Bounds: + * - A relay with no cursor yet (never paged) is skipped — there is no window position to misalign. + * - The rewind never moves [reachedUntil] above the pinned [floor] (history lives strictly below it; + * a pruned message newer than the floor is the live tail's concern, not this window's). + * - A relay whose reached point is already shallower than the pruned band needs no rewind. + */ + fun rewindTo(newestPrunedUntil: Map) { + val floorAt = floor ?: return + newestPrunedUntil.forEach { (relay, prunedUntil) -> + val c = cursors.get(relay) ?: return@forEach + val reached = c.reachedUntil ?: return@forEach + // Re-include the newest pruned event: the next page asks `until = reached - 1`, so reached must + // sit one tick above it. Never climb above the floor. + val target = minOf(prunedUntil + 1, floorAt) + if (reached < target) { + c.reachedUntil = target + c.requestedUntil = null + c.done = false + c.pageCount = 0 + c.pageOldest = Long.MAX_VALUE + } + } + } + + /** Relays from [all] that have been armed (advanced at least once) and are not yet [isDone]. */ + fun armedRelays(all: Collection): List = + all.filter { + val c = cursor(it) + c.requestedUntil != null && !c.done + } + + /** + * The oldest point reached across [relays] — the minimum reached cursor (how far back paging has + * gone). Relays that haven't delivered count as [start]. Null when [relays] is empty. + */ + fun deepestReached( + relays: Collection, + start: Long, + ): Long? = relays.takeIf { it.isNotEmpty() }?.minOf { cursor(it).reachedUntil ?: start } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt new file mode 100644 index 0000000000..125a3da2ba --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt @@ -0,0 +1,33 @@ +/* + * 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 + +/** How far back one relay has paged through its history, for a per-relay progress display. */ +data class RelayPagingProgress( + // The oldest createdAt this relay has loaded down to (its `until` cursor). This is the "loaded back + // to" point; it slides down (older) as the relay pages further back. + val reachedUntil: Long, + // The relay answered an empty page: it has nothing older, it has reached the bottom of its window. + val done: Boolean, + // The relay isn't answering right now (auth-walled CLOSE / unreachable / slow). It is NOT abandoned + // — its subscription stays open and it keeps trying to catch up — but it isn't currently advancing. + val stalled: Boolean, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt index b971711e4b..8fdd3c92d8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt @@ -22,8 +22,19 @@ package com.vitorpamplona.quartz.nip59Giftwrap import com.vitorpamplona.quartz.nip01Core.core.HexKey +/** + * A lightweight reference to the host event a [WrappedEvent] was extracted from — kept on the inner + * event so callers can broadcast / delete / locate the outer wrap without holding the full event. + * + * [createdAt] is the host's own `created_at` (e.g. the kind:1059 gift-wrap timestamp, randomized per + * NIP-59), carried here so a decrypted rumor self-describes its outer-wrap time. The history pager + * cursors page gift wraps by that outer time, so the prune path uses it to realign the per-relay + * download window when a wrapped message is pruned (the chatroom only keeps the inner rumor, whose + * `created_at` is the real message time, not the wrap time). + */ class HostStub( val id: HexKey, val pubKey: HexKey, val kind: Int, + val createdAt: Long, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt index 3591a5d7a1..ad2c9cf873 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt @@ -70,7 +70,7 @@ class SealedRumorEvent( val event = rumor.mergeWith(this) if (event is WrappedEvent) { - event.host = host ?: HostStub(this.id, this.pubKey, this.kind) + event.host = host ?: HostStub(this.id, this.pubKey, this.kind, this.createdAt) } innerEventId = event.id diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index af7298adf8..7d19424ddc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -74,7 +74,7 @@ open class GiftWrapEvent( val gift = fromJson(giftStr) if (gift is WrappedEvent) { - gift.host = HostStub(this.id, this.pubKey, this.kind) + gift.host = HostStub(this.id, this.pubKey, this.kind, this.createdAt) } innerEventId = gift.id diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt index b007a052eb..3a49a2df69 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils expect fun platform(): String expect fun currentTimeSeconds(): Long + +expect fun currentTimeMillis(): Long diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt index 90ca1272c8..b8f1a09ec8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -36,6 +36,8 @@ object TimeUtils { fun now() = currentTimeSeconds() + fun nowMillis() = currentTimeMillis() + fun tenSecondsFromNow() = now() + TEN_SECONDS fun tenSecondsAgo() = now() - TEN_SECONDS diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt index 9f2b842edb..fee99bbf15 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt @@ -31,3 +31,5 @@ actual fun currentTimeSeconds(): Long { // NSDate().timeIntervalSince1970 returns seconds since 1970-01-01 00:00:00 UTC return (NSDate().timeIntervalSince1970).toLong() } + +actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong() diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt new file mode 100644 index 0000000000..1c1a43f1e1 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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 kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins down the relay-side contract the whole [RelayLoadingCursors] / `BackwardRelayPager` design rests on, + * against the in-process relay: a backward `until`+`limit` walk returns each event **exactly once** + * (no re-download), in **newest-first** capped pages, and an **empty page + EOSE** is the gap-proof + * stop. If a relay ever stopped honouring this (e.g. oldest-first, or ignoring `until`), these break — + * which is exactly the signal the pager's correctness depends on. + */ +class UntilLimitPagingRelayTest : RelayClientTest() { + @Test + fun backwardUntilLimitWalkCoversEveryEventOnceAndStopsOnEmptyPage() = + runBlocking { + // 250 regular events, createdAt 1..250 (distinct pubkeys so none collapse). + defaultRelay.preload(SyntheticEvents.batch(TOTAL, kind = KIND)) + + val seenIds = mutableSetOf() + var totalReceived = 0 + var pages = 0 + var until: Long? = null + + while (pages < SAFETY_CAP) { + val (events, eose) = + client.collectUntilEose( + defaultRelayUrl, + Filter(kinds = listOf(KIND), until = until, limit = LIMIT), + ) + 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") + // Newest-first + cursor honoured: nothing newer than the cursor leaks into a later page. + until?.let { cursor -> assertTrue(events.all { it.createdAt <= cursor }, "page must be older than the cursor") } + + events.forEach { e: Event -> + seenIds.add(e.id) + totalReceived++ + } + until = events.minOf { it.createdAt } - 1 + } + + // No re-download: total delivered equals the corpus, and every id is distinct. + assertEquals(TOTAL, totalReceived, "no event should be delivered twice across pages") + assertEquals(TOTAL, seenIds.size, "every event fetched exactly once") + // 250 / 100 → 100 + 100 + 50, then an empty page stops the walk. + assertEquals(3, pages) + } + + @Test + fun anEmptyRelayAnswersOneEmptyPageWithEose() = + runBlocking { + val (events, eose) = + client.collectUntilEose( + defaultRelayUrl, + Filter(kinds = listOf(KIND), until = null, limit = LIMIT), + ) + assertTrue(eose) + assertEquals(0, events.size) + } + + companion object { + private const val KIND = 1 + private const val TOTAL = 250 + private const val LIMIT = 100 + private const val SAFETY_CAP = 10 + } +} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt index d4b4f6b5e5..d6979a6e06 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils actual fun platform() = "JVM" actual fun currentTimeSeconds() = System.currentTimeMillis() / 1000 + +actual fun currentTimeMillis() = System.currentTimeMillis() diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt index 7e4b5fc506..0b02bf8b58 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt @@ -38,3 +38,12 @@ actual fun currentTimeSeconds(): Long { return ts.tv_sec } } + +@OptIn(ExperimentalForeignApi::class) +actual fun currentTimeMillis(): Long { + memScoped { + val ts = alloc() + clock_gettime(CLOCK_REALTIME, ts.ptr) + return ts.tv_sec * 1000 + ts.tv_nsec / 1_000_000 + } +} diff --git a/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt b/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt index 75f7e49519..49c1daf844 100644 --- a/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt +++ b/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt @@ -26,3 +26,5 @@ import platform.Foundation.timeIntervalSince1970 actual fun platform() = "macOS" actual fun currentTimeSeconds(): Long = (NSDate().timeIntervalSince1970).toLong() + +actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()