Ask a space for what a badge reads rather than syncing a month of it

This commit is contained in:
Coracle-Bot 2026-09-25 21:08:00 +00:00
parent fecee114e7
commit 659e4937e5
3 changed files with 108 additions and 78 deletions

View file

@ -1841,6 +1841,16 @@ Acceptance:
- Opening the list says there are no polls, and afterwards the space stops - Opening the list says there are no polls, and afterwards the space stops
offering the section. 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 ## Out of scope
Features the e2e suite cannot exercise, and what stops it. Features the e2e suite cannot exercise, and what stops it.

View file

@ -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 space = scenario.space("space")
const other = scenario.space("other") 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 bob = await as(users.bob, roomPath(other.url, "general"))
const spaceButton = spaceNavItem(bob, space.name) 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) 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}) => { test("US-120 read what a notification says", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")

View file

@ -4,16 +4,17 @@ import {ago, assoc, call, noop, MONTH, WEEK} from "@welshman/lib"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import { import {
APP_DATA, APP_DATA,
DELETE,
FOLLOWS, FOLLOWS,
MESSAGE, MESSAGE,
MUTES, MUTES,
PIN,
PINBOARD, PINBOARD,
POLL_RESPONSE, POLL_RESPONSE,
RELAY_ADD_MEMBER, RELAY_ADD_MEMBER,
RELAY_MEMBERS, RELAY_MEMBERS,
RELAY_REMOVE_MEMBER, RELAY_REMOVE_MEMBER,
RELAY_ROLE, RELAY_ROLE,
REPORT,
ROOM_ADD_MEMBER, ROOM_ADD_MEMBER,
ROOM_ADMINS, ROOM_ADMINS,
ROOM_CREATE_PERMISSION, ROOM_CREATE_PERMISSION,
@ -30,7 +31,7 @@ import {
} from "@welshman/util" } from "@welshman/util"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import type {FollowListReader, RoomListReader} from "@welshman/domain" import type {FollowListReader, RoomListReader} from "@welshman/domain"
import {merged, synced, withGetter} from "@welshman/store" import {merged, synced, throttled, withGetter} from "@welshman/store"
import { import {
FollowLists, FollowLists,
MessagingRelayLists, MessagingRelayLists,
@ -38,7 +39,6 @@ import {
RoomLists, RoomLists,
SearchRelayLists, SearchRelayLists,
Sync, Sync,
makeRoomKey,
} from "@welshman/app" } from "@welshman/app"
import { import {
app, app,
@ -53,11 +53,12 @@ import {
relayLists, relayLists,
relays, relays,
roomLists, roomLists,
rooms,
router, router,
searchRelayLists, searchRelayLists,
} from "@app/core" } from "@app/core"
import {LIVEKIT_PARTICIPANTS} from "@app/call" 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 {INDEXER_RELAYS, PLATFORM_RELAYS} from "@app/env"
import {FEATURED_CONTENT_D} from "@app/featured" import {FEATURED_CONTENT_D} from "@app/featured"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
@ -156,24 +157,7 @@ const syncUserSpaceMembership = (url: string) => {
{kinds: [RELAY_ADD_MEMBER], "#p": [$pubkey], limit: 1}, {kinds: [RELAY_ADD_MEMBER], "#p": [$pubkey], limit: 1},
{kinds: [RELAY_REMOVE_MEMBER], "#p": [$pubkey], limit: 1}, {kinds: [RELAY_REMOVE_MEMBER], "#p": [$pubkey], limit: 1},
{kinds: [ROOM_CREATE_PERMISSION], "#p": [$pubkey], limit: 1}, {kinds: [ROOM_CREATE_PERMISSION], "#p": [$pubkey], limit: 1},
], {kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#p": [$pubkey]},
})
}
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},
], ],
}) })
} }
@ -182,34 +166,22 @@ const syncUserRoomMembership = (url: string, h: string) => {
} }
const syncUserData = () => { const syncUserData = () => {
const unsubscribersByKey = new Map<string, Unsubscriber>() const unsubscribersByUrl = new Map<string, Unsubscriber>()
const $pubkey = app.get().user?.pubkey const $pubkey = app.get().user?.pubkey
const syncRoomList = ($roomList: Maybe<RoomListReader>) => { const syncRoomList = ($roomList: Maybe<RoomListReader>) => {
if ($roomList) { if ($roomList) {
const keys = new Set<string>() const urls = new Set(getSpaceUrls($roomList))
for (const url of getSpaceUrls($roomList)) { for (const url of urls) {
if (!unsubscribersByKey.has(url)) { if (!unsubscribersByUrl.has(url)) {
unsubscribersByKey.set(url, syncUserSpaceMembership(url)) unsubscribersByUrl.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 [key, unsubscribe] of unsubscribersByKey.entries()) { for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
if (!keys.has(key)) { if (!urls.has(url)) {
unsubscribersByKey.delete(key) unsubscribersByUrl.delete(url)
unsubscribe() unsubscribe()
} }
} }
@ -251,7 +223,7 @@ const syncUserData = () => {
const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork) const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork)
return () => { return () => {
unsubscribersByKey.forEach(call) unsubscribersByUrl.forEach(call)
unsubscribeRoomList() unsubscribeRoomList()
unsubscribeRelayList() unsubscribeRelayList()
unsubscribeFollowList() unsubscribeFollowList()
@ -260,46 +232,58 @@ const syncUserData = () => {
// Spaces // Spaces
const syncSpace = (url: string) => { const syncSpaceActivity = (url: string) => {
const controller = new AbortController()
const {signal} = controller
const asked = new Set<string>()
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() const controller = new AbortController()
// Low cardinality we want everything for
pullAndListen({ pullAndListen({
url, url,
signal: controller.signal, signal: controller.signal,
filters: [ filters: [
{kinds: [RELAY_MEMBERS, RELAY_ROLE]}, {kinds: [RELAY_ROLE]},
{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}, {kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]},
], {kinds: [ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS, PINBOARD, ROOM_PINS]},
}) {kinds: [ROOM_JOIN, ROOM_LEAVE], since: ago(MONTH)},
// 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)}),
], ],
}) })
@ -316,6 +300,9 @@ const syncSpace = (url: string) => {
const syncSpaces = () => { const syncSpaces = () => {
const unsubscribersByUrl = new Map<string, Unsubscriber>() const unsubscribersByUrl = new Map<string, Unsubscriber>()
let detailUrl: Maybe<string>
let unsubscribeDetail: Unsubscriber = noop
const unsubscribe = merged([userRoomList, page]).subscribe(([$roomList, $page]) => { const unsubscribe = merged([userRoomList, page]).subscribe(([$roomList, $page]) => {
const urls = new Set(getSpaceUrls($roomList)) const urls = new Set(getSpaceUrls($roomList))
const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined
@ -335,9 +322,15 @@ const syncSpaces = () => {
// Start syncing for new spaces // Start syncing for new spaces
for (const url of urls) { for (const url of urls) {
if (!unsubscribersByUrl.has(url)) { 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 () => { return () => {
@ -345,6 +338,7 @@ const syncSpaces = () => {
unsubscriber() unsubscriber()
} }
unsubscribeDetail()
unsubscribe() unsubscribe()
} }
} }