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
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.

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 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")

View file

@ -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<string, Unsubscriber>()
const unsubscribersByUrl = new Map<string, Unsubscriber>()
const $pubkey = app.get().user?.pubkey
const syncRoomList = ($roomList: Maybe<RoomListReader>) => {
if ($roomList) {
const keys = new Set<string>()
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<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()
// 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<string, Unsubscriber>()
let detailUrl: Maybe<string>
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()
}
}