From 659e4937e5d74ea3c5ed9023199f749ad72e8d0f Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Fri, 25 Sep 2026 21:08:00 +0000 Subject: [PATCH] Ask a space for what a badge reads rather than syncing a month of it --- e2e/USER_STORIES.md | 10 +++ e2e/specs/notifications.spec.ts | 28 +++++- src/app/sync.ts | 148 +++++++++++++++----------------- 3 files changed, 108 insertions(+), 78 deletions(-) diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 0b1c0966..8ec6b8a0 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1841,6 +1841,16 @@ Acceptance: - Opening the list says there are no polls, and afterwards the space stops offering the section. +### US-132 — See a room whose newest message is old + +As bob, I want a room nobody has spoken in for weeks to still tell me it has +something I have not read, so that a quiet room is not a room I forget. + +Acceptance: + +- A message two months old in a room bob has never opened raises a dot on the + space, and the room carries the same dot once the space is open. + ## Out of scope Features the e2e suite cannot exercise, and what stops it. diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index 206fcc91..51a35746 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -1003,7 +1003,6 @@ test("US-124 reach a badge raised by content the space doesn't have", async ({se const space = scenario.space("space") const other = scenario.space("other") - // A space hides its own dot while you are in it, so the badge is only readable from elsewhere const bob = await as(users.bob, roomPath(other.url, "general")) const spaceButton = spaceNavItem(bob, space.name) @@ -1026,6 +1025,33 @@ test("US-124 reach a badge raised by content the space doesn't have", async ({se await expect(pollsNav).toHaveCount(0) }) +test("US-132 see a room whose newest message is old", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + other.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + other.join(user.bob, "general") + + space.message(user.alice, "general", "still nobody bought a sextant", at(60, DAY)) + }) + + const space = scenario.space("space") + const other = scenario.space("other") + + const bob = await as(users.bob, roomPath(other.url, "general")) + const spaceButton = spaceNavItem(bob, space.name) + + await expect(unreadDot(spaceButton)).toBeVisible() + + await spaceButton.click() + + await expect(unreadDot(roomLink(bob, "General"))).toBeVisible() +}) + test("US-120 read what a notification says", async ({seed, as}) => { const scenario = await seed(({relay, user, at}) => { const space = relay("space") diff --git a/src/app/sync.ts b/src/app/sync.ts index 951a133d..16e0b937 100644 --- a/src/app/sync.ts +++ b/src/app/sync.ts @@ -4,16 +4,17 @@ import {ago, assoc, call, noop, MONTH, WEEK} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import { APP_DATA, + DELETE, FOLLOWS, MESSAGE, MUTES, - PIN, PINBOARD, POLL_RESPONSE, RELAY_ADD_MEMBER, RELAY_MEMBERS, RELAY_REMOVE_MEMBER, RELAY_ROLE, + REPORT, ROOM_ADD_MEMBER, ROOM_ADMINS, ROOM_CREATE_PERMISSION, @@ -30,7 +31,7 @@ import { } from "@welshman/util" import type {Filter} from "@welshman/util" import type {FollowListReader, RoomListReader} from "@welshman/domain" -import {merged, synced, withGetter} from "@welshman/store" +import {merged, synced, throttled, withGetter} from "@welshman/store" import { FollowLists, MessagingRelayLists, @@ -38,7 +39,6 @@ import { RoomLists, SearchRelayLists, Sync, - makeRoomKey, } from "@welshman/app" import { app, @@ -53,11 +53,12 @@ import { relayLists, relays, roomLists, + rooms, router, searchRelayLists, } from "@app/core" import {LIVEKIT_PARTICIPANTS} from "@app/call" -import {REACTION_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content" +import {CONTENT_KINDS, makeCommentFilter} from "@app/content" import {INDEXER_RELAYS, PLATFORM_RELAYS} from "@app/env" import {FEATURED_CONTENT_D} from "@app/featured" import {decodeRelay} from "@app/relays" @@ -156,24 +157,7 @@ const syncUserSpaceMembership = (url: string) => { {kinds: [RELAY_ADD_MEMBER], "#p": [$pubkey], limit: 1}, {kinds: [RELAY_REMOVE_MEMBER], "#p": [$pubkey], limit: 1}, {kinds: [ROOM_CREATE_PERMISSION], "#p": [$pubkey], limit: 1}, - ], - }) - } - - return () => controller.abort() -} - -const syncUserRoomMembership = (url: string, h: string) => { - const $pubkey = app.get().user?.pubkey - const controller = new AbortController() - - if ($pubkey) { - pullAndListen({ - url, - signal: controller.signal, - filters: [ - {kinds: [ROOM_ADD_MEMBER], "#p": [$pubkey], "#h": [h], limit: 1}, - {kinds: [ROOM_REMOVE_MEMBER], "#p": [$pubkey], "#h": [h], limit: 1}, + {kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#p": [$pubkey]}, ], }) } @@ -182,34 +166,22 @@ const syncUserRoomMembership = (url: string, h: string) => { } const syncUserData = () => { - const unsubscribersByKey = new Map() + const unsubscribersByUrl = new Map() const $pubkey = app.get().user?.pubkey const syncRoomList = ($roomList: Maybe) => { if ($roomList) { - const keys = new Set() + const urls = new Set(getSpaceUrls($roomList)) - for (const url of getSpaceUrls($roomList)) { - if (!unsubscribersByKey.has(url)) { - unsubscribersByKey.set(url, syncUserSpaceMembership(url)) - } - - keys.add(url) - - for (const h of $roomList.roomsForUrl(url)) { - const key = makeRoomKey(url, h) - - if (!unsubscribersByKey.has(key)) { - unsubscribersByKey.set(key, syncUserRoomMembership(url, h)) - } - - keys.add(key) + for (const url of urls) { + if (!unsubscribersByUrl.has(url)) { + unsubscribersByUrl.set(url, syncUserSpaceMembership(url)) } } - for (const [key, unsubscribe] of unsubscribersByKey.entries()) { - if (!keys.has(key)) { - unsubscribersByKey.delete(key) + for (const [url, unsubscribe] of unsubscribersByUrl.entries()) { + if (!urls.has(url)) { + unsubscribersByUrl.delete(url) unsubscribe() } } @@ -251,7 +223,7 @@ const syncUserData = () => { const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork) return () => { - unsubscribersByKey.forEach(call) + unsubscribersByUrl.forEach(call) unsubscribeRoomList() unsubscribeRelayList() unsubscribeFollowList() @@ -260,46 +232,58 @@ const syncUserData = () => { // Spaces -const syncSpace = (url: string) => { +const syncSpaceActivity = (url: string) => { + const controller = new AbortController() + const {signal} = controller + const asked = new Set() + + network.get().request({ + relays: [url], + signal, + filters: [ + {kinds: [MESSAGE], limit: 1}, + {kinds: CONTENT_KINDS, since: ago(WEEK)}, + makeCommentFilter(CONTENT_KINDS, {since: ago(WEEK)}), + {kinds: [DELETE, REPORT, POLL_RESPONSE], since: ago(WEEK)}, + ], + }) + + pullAndListen({url, signal, filters: [{kinds: [ROOM_META, ROOM_DELETE, RELAY_MEMBERS]}]}) + + const unsubscribeRooms = throttled(300, rooms.get().forUrl(url).$).subscribe($rooms => { + const hs = $rooms.map(room => room.h).filter(h => !asked.has(h)) + + if (hs.length > 0) { + for (const h of hs) { + asked.add(h) + } + + network.get().request({ + relays: [url], + signal, + autoClose: true, + filters: hs.map(h => ({kinds: [MESSAGE], "#h": [h], limit: 1})), + }) + } + }) + + return () => { + unsubscribeRooms() + controller.abort() + } +} + +const syncSpaceDetail = (url: string) => { const controller = new AbortController() - // Low cardinality we want everything for pullAndListen({ url, signal: controller.signal, filters: [ - {kinds: [RELAY_MEMBERS, RELAY_ROLE]}, + {kinds: [RELAY_ROLE]}, {kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}, - ], - }) - - // Higher cardinality stuff we want as much as we can get - pullAndListen({ - url, - signal: controller.signal, - filters: [ - { - kinds: [ - ROOM_META, - ROOM_ADMINS, - ROOM_MEMBERS, - ROOM_DELETE, - LIVEKIT_PARTICIPANTS, - PINBOARD, - ROOM_PINS, - ], - }, - ], - }) - - // Recent stuff, best effort - pullAndListen({ - url, - signal: controller.signal, - filters: [ - {kinds: [...CONTENT_KINDS, MESSAGE, PIN, ROOM_JOIN, ROOM_LEAVE], since: ago(MONTH)}, - {kinds: [...REACTION_KINDS, POLL_RESPONSE], since: ago(WEEK)}, - makeCommentFilter(CONTENT_KINDS, {since: ago(WEEK)}), + {kinds: [ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS, PINBOARD, ROOM_PINS]}, + {kinds: [ROOM_JOIN, ROOM_LEAVE], since: ago(MONTH)}, ], }) @@ -316,6 +300,9 @@ const syncSpace = (url: string) => { const syncSpaces = () => { const unsubscribersByUrl = new Map() + let detailUrl: Maybe + let unsubscribeDetail: Unsubscriber = noop + const unsubscribe = merged([userRoomList, page]).subscribe(([$roomList, $page]) => { const urls = new Set(getSpaceUrls($roomList)) const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined @@ -335,9 +322,15 @@ const syncSpaces = () => { // Start syncing for new spaces for (const url of urls) { if (!unsubscribersByUrl.has(url)) { - unsubscribersByUrl.set(url, syncSpace(url)) + unsubscribersByUrl.set(url, syncSpaceActivity(url)) } } + + if (detailUrl !== currentUrl) { + detailUrl = currentUrl + unsubscribeDetail() + unsubscribeDetail = currentUrl ? syncSpaceDetail(currentUrl) : noop + } }) return () => { @@ -345,6 +338,7 @@ const syncSpaces = () => { unsubscriber() } + unsubscribeDetail() unsubscribe() } }