From 97b861dd5e0b0969dcf710794b7f7f740be67aed Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 11:34:07 -0400 Subject: [PATCH] fix(notifications): remove the 7-day since floor that emptied the tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notifications query pinned `since` to `oneWeekAgo()` whenever it had no EOSE timestamp. Combined with two other facts that produced a deadlock: - the EOSE `since` map is in-memory only (`SincePerRelayMap = MutableMap<..>`), so EVERY cold start re-pinned the window to 7 days; and - the backward-paging escape hatch (`lastNoteCreatedAtIfFilled()`) only arms once the feed holds a FULL page (`notes.size >= localFilter.limit()`). The feed could not fill a page because the query only asked for a week, and the query could not widen because the feed never filled. Any account whose last inbound mention was older than 7 days saw a near-empty Notifications tab forever — including a fresh install of a long-established account. Worse, the floor was applied to `filterSummaryNotificationsToPubkey` (kinds 1/7/6/9735 — the overwhelming bulk of notifications), which was not even wired to the paging fallback that the secondary per-key kinds received. Home is the precedent and does not do this: `filterHomePostsByAllFollows` passes `since ?: boundary`, i.e. plain null on a cold start, relying on the relay-side `limit` to bound the response. Notifications was the outlier. These filters are `#p`-scoped to the user's own key and carry a `limit` (2000/500/200/20), so an all-time query is one index scan returning at most `limit` events newest-first — same cost, strictly more useful. Measured on the test account before the fix: 163 events p-tagging it exist on relays and are fetchable (100 kind-1 and 100 kind-7, both hitting the query cap), 77 of which clear the follow gate — yet the tab rendered ~3, because the account's most recent inbound activity was a month old. After the fix the tab scrolls back through Feb 2025. Co-Authored-By: Claude Opus 4.8 --- ...NotificationsEoseFromInboxRelaysManager.kt | 27 ++++++++++++++++--- ...otificationsEoseFromRandomRelaysManager.kt | 8 +++--- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index c8f58630ce..0008693396 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -51,17 +50,35 @@ class AccountNotificationsEoseFromInboxRelaysManager( key: AccountQueryState, since: SincePerRelayMap?, ): List { + // Backward-paging boundary: once the feed has filled a page, ask for everything older than + // its oldest card. It stays null until then — see the note on the missing week floor below, + // which is what let it stay null forever on a quiet inbox. + val pagingBoundary = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() + val inbox = key.account.notificationRelays.flow.value.flatMap { + // No `since` floor on the first fetch. These filters are scoped by `#p` to my own + // key and carry a relay-side `limit`, so an all-time query costs one index scan and + // returns at most `limit` events, newest first — exactly what Home does (it passes + // `since ?: boundary`, i.e. null on a cold start). + // + // This used to fall back to `oneWeekAgo()`, which silently emptied the tab for + // anyone whose last mention was older than a week: EOSE `since` is in-memory only, + // so EVERY cold start re-pinned the window to 7 days, and the paging boundary above + // could never rescue it — it only arms once the feed holds a full page, and the feed + // could not fill because the query only ever asked for a week. A fresh install of an + // established account hit the same deadlock. + val notificationSince = since?.get(it)?.time ?: pagingBoundary + filterSummaryNotificationsToPubkey( relay = it, pubkey = user(key).pubkeyHex, - since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(), + since = notificationSince, ) + filterNotificationsToPubkey( relay = it, pubkey = user(key).pubkeyHex, - since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo(), + since = notificationSince, ) } @@ -76,7 +93,9 @@ class AccountNotificationsEoseFromInboxRelaysManager( relay = relay, pubkey = user(key).pubkeyHex, groupIds = groupIds.distinct(), - since = since?.get(relay)?.time ?: TimeUtils.oneWeekAgo(), + // Same reasoning as the inbox filters above: `#p` + `#h` + `limit = 200` + // already bound this, so a week floor only hides older group activity. + since = since?.get(relay)?.time ?: pagingBoundary, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt index 9aa0acc87d..7cfc8cd385 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt @@ -27,7 +27,6 @@ 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.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -51,8 +50,11 @@ class AccountNotificationsEoseFromRandomRelaysManager( key: AccountQueryState, since: SincePerRelayMap?, ): List { - // only loads this after the feed is built - val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo() + // only loads this after the feed is built, so it stays null on a quiet inbox. No week floor + // behind it: this probe is `#p`-scoped to me with `limit = 20`, so relays answer with the 20 + // newest either way — the floor only ever hid notifications older than a week, and since the + // boundary above needs a full page to arm, a quiet inbox could never page past it. + val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() return (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap { val since = since?.get(it)?.time ?: defaultSince filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since)