Persist direct messages so history survives relay retention (#393)
Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/393 Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
This commit is contained in:
parent
cffc0acfde
commit
ebde17fed0
7 changed files with 190 additions and 6 deletions
|
|
@ -66,6 +66,12 @@ Every socket the browser opens is terminated in the node process, and the only o
|
||||||
is the loopback connection `zooid/transport.ts` makes to the container the test started.
|
is the loopback connection `zooid/transport.ts` makes to the container the test started.
|
||||||
`assertNoLeaks()` fails a test that touched a url the scenario never declared.
|
`assertNoLeaks()` fails a test that touched a url the scenario never declared.
|
||||||
|
|
||||||
|
Because the branch is taken per socket rather than once, retention is expressible here too:
|
||||||
|
`forgetRelay(context, url)` sends that context down the empty-relay side from its next connection
|
||||||
|
on, without the url becoming a leak. A reload after it is a client coming back to a relay that has
|
||||||
|
dropped what it was holding, which is what separates history a client kept from history it is
|
||||||
|
reading back off the wire.
|
||||||
|
|
||||||
Interception is installed by `as()` and `visit()`, on a context each of them creates, so a page that
|
Interception is installed by `as()` and `visit()`, on a context each of them creates, so a page that
|
||||||
came from anywhere else has none of it. Playwright's own `context` fixture — and the `page` fixture
|
came from anywhere else has none of it. Playwright's own `context` fixture — and the `page` fixture
|
||||||
built on it — is therefore overridden to throw, so a spec written the ordinary way fails immediately
|
built on it — is therefore overridden to throw, so a spec written the ordinary way fails immediately
|
||||||
|
|
|
||||||
|
|
@ -541,6 +541,18 @@ Acceptance:
|
||||||
- A messaging relay list written by another client, naming the same relay without the trailing
|
- A messaging relay list written by another client, naming the same relay without the trailing
|
||||||
slash, is honoured the same way.
|
slash, is honoured the same way.
|
||||||
|
|
||||||
|
### US-109 — Keep a conversation I have already read
|
||||||
|
|
||||||
|
As alice, I want a conversation I have already read to stay readable, so that my history does not
|
||||||
|
shrink to whatever my messaging relays happen to still be holding.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- With a conversation open and read, closing the app and opening it again shows the same messages,
|
||||||
|
whether or not the relay they arrived from still serves them.
|
||||||
|
- A message deleted or edited out of that conversation stays gone across the same restart.
|
||||||
|
- A reaction I left on one of its messages is still there after the same restart.
|
||||||
|
|
||||||
## Articles & threads
|
## Articles & threads
|
||||||
|
|
||||||
### US-037 — Write and publish an article
|
### US-037 — Write and publish an article
|
||||||
|
|
|
||||||
41
e2e/harness/app/cache.ts
Normal file
41
e2e/harness/app/cache.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type {Page} from "@playwright/test"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
|
||||||
|
// Must match the database name and the `events` table in src/app/storage.ts, which are scoped to
|
||||||
|
// one identity.
|
||||||
|
const databaseName = (pubkey: string) => `flotilla-9gl-${pubkey}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this user's client has written to disk so far. Events reach indexeddb in three-second
|
||||||
|
* batches and nothing in the ui says when one has landed, so a spec about what survives a restart
|
||||||
|
* has to read the cache to know the restart is testing anything: a reload before the batch would
|
||||||
|
* fail whether or not the events were ever going to be persisted.
|
||||||
|
*/
|
||||||
|
export const readCachedEvents = (page: Page, pubkey: string): Promise<TrustedEvent[]> =>
|
||||||
|
page.evaluate(async name => {
|
||||||
|
const open = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(name)
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
|
||||||
|
// An unversioned open creates the database when it is missing, so a client that has not written
|
||||||
|
// anything yet answers with an empty one rather than with a store to read.
|
||||||
|
if (!open.objectStoreNames.contains("events")) {
|
||||||
|
open.close()
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await new Promise<{event: TrustedEvent}[]>((resolve, reject) => {
|
||||||
|
const request = open.transaction("events", "readonly").objectStore("events").getAll()
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
|
||||||
|
open.close()
|
||||||
|
|
||||||
|
return items.map(item => item.event)
|
||||||
|
}, databaseName(pubkey))
|
||||||
|
|
@ -28,7 +28,8 @@ export type {Scenario} from "./seed/scenario"
|
||||||
export type {SeededRumor, SeededSpace} from "./seed/space"
|
export type {SeededRumor, SeededSpace} from "./seed/space"
|
||||||
export type {TenantName} from "./zooid/config"
|
export type {TenantName} from "./zooid/config"
|
||||||
export type {TranscriptEntry} from "./net/websocket"
|
export type {TranscriptEntry} from "./net/websocket"
|
||||||
export {formatTranscript, getTranscript} from "./net/websocket"
|
export {forgetRelay, formatTranscript, getTranscript} from "./net/websocket"
|
||||||
|
export {readCachedEvents} from "./app/cache"
|
||||||
export {
|
export {
|
||||||
assertNoBlockedRequests,
|
assertNoBlockedRequests,
|
||||||
getHosting,
|
getHosting,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export type TranscriptEntry = {
|
||||||
type Traffic = {
|
type Traffic = {
|
||||||
transcript: TranscriptEntry[]
|
transcript: TranscriptEntry[]
|
||||||
leaks: Set<string>
|
leaks: Set<string>
|
||||||
|
forgotten: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
const trafficByContext = new WeakMap<BrowserContext, Traffic>()
|
const trafficByContext = new WeakMap<BrowserContext, Traffic>()
|
||||||
|
|
@ -56,6 +57,10 @@ const openEmptyRelay = (): RelayConnection => {
|
||||||
const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => {
|
const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => {
|
||||||
const url = normalizeRelayUrl(route.url())
|
const url = normalizeRelayUrl(route.url())
|
||||||
const connection = call(() => {
|
const connection = call(() => {
|
||||||
|
if (traffic.forgotten.has(url)) {
|
||||||
|
return openEmptyRelay()
|
||||||
|
}
|
||||||
|
|
||||||
const relay = zooid.relays.get(url)
|
const relay = zooid.relays.get(url)
|
||||||
|
|
||||||
if (relay) {
|
if (relay) {
|
||||||
|
|
@ -94,7 +99,7 @@ const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => {
|
||||||
* as a leak.
|
* as a leak.
|
||||||
*/
|
*/
|
||||||
export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => {
|
export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => {
|
||||||
const traffic: Traffic = {transcript: [], leaks: new Set()}
|
const traffic: Traffic = {transcript: [], leaks: new Set(), forgotten: new Set()}
|
||||||
|
|
||||||
trafficByContext.set(context, traffic)
|
trafficByContext.set(context, traffic)
|
||||||
|
|
||||||
|
|
@ -106,6 +111,13 @@ export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) =>
|
||||||
|
|
||||||
export const getTranscript = (context: BrowserContext) => getTraffic(context).transcript
|
export const getTranscript = (context: BrowserContext) => getTraffic(context).transcript
|
||||||
|
|
||||||
|
// Retention, as this context sees it: from here on the relay answers like one that never held
|
||||||
|
// anything, while staying a url the scenario declared rather than becoming a leak. Sockets already
|
||||||
|
// open keep the relay they were opened against — `serve` resolves once, at open — so the drop takes
|
||||||
|
// effect on the next connection, which is what a reload gives it.
|
||||||
|
export const forgetRelay = (context: BrowserContext, url: string) =>
|
||||||
|
getTraffic(context).forgotten.add(normalizeRelayUrl(url))
|
||||||
|
|
||||||
// Every frame in both directions, oldest first. Attach it to a failing test to see what the client
|
// Every frame in both directions, oldest first. Attach it to a failing test to see what the client
|
||||||
// actually said, and to whom.
|
// actually said, and to whom.
|
||||||
export const formatTranscript = (context: BrowserContext) =>
|
export const formatTranscript = (context: BrowserContext) =>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
import {npubEncode} from "nostr-tools/nip19"
|
import {npubEncode} from "nostr-tools/nip19"
|
||||||
import type {Locator, Page} from "@playwright/test"
|
import type {Locator, Page} from "@playwright/test"
|
||||||
import {DAY, HOUR, MINUTE} from "@welshman/lib"
|
import {DAY, HOUR, MINUTE} from "@welshman/lib"
|
||||||
|
import {DIRECT_MESSAGE, REACTION} from "@welshman/util"
|
||||||
import {MessagingRelayList, RelayList} from "@welshman/domain"
|
import {MessagingRelayList, RelayList} from "@welshman/domain"
|
||||||
import {expect, makeTestUser, test, users} from "../harness"
|
import {
|
||||||
|
expect,
|
||||||
|
forgetRelay,
|
||||||
|
makeTestUser,
|
||||||
|
readCachedEvents,
|
||||||
|
roomPath,
|
||||||
|
test,
|
||||||
|
users,
|
||||||
|
} from "../harness"
|
||||||
import type {SeededRumor, SeededSpace, TestUser} from "../harness"
|
import type {SeededRumor, SeededSpace, TestUser} from "../harness"
|
||||||
|
|
||||||
// The path the app builds for a conversation: the other participants' pubkeys, sorted and joined
|
// The path the app builds for a conversation: the other participants' pubkeys, sorted and joined
|
||||||
|
|
@ -19,8 +28,8 @@ const pathPattern = (path: string) => new RegExp(path.replace(/[.?+*()[\]]/g, "\
|
||||||
// no kind-10002 to no relays at all. So a person here is a membership, a profile and a relay list.
|
// no kind-10002 to no relays at all. So a person here is a membership, a profile and a relay list.
|
||||||
// Membership is also what lets a gift wrap addressed to them be stored: zooid authorizes a
|
// Membership is also what lets a gift wrap addressed to them be stored: zooid authorizes a
|
||||||
// kind-1059 by the member named in its p tag.
|
// kind-1059 by the member named in its p tag.
|
||||||
const seedPerson = (space: SeededSpace, user: TestUser, name: string) => {
|
const seedPerson = (space: SeededSpace, user: TestUser, name: string, ...rooms: string[]) => {
|
||||||
space.join(user)
|
space.join(user, ...rooms)
|
||||||
space.profile(user, {name})
|
space.profile(user, {name})
|
||||||
space.event(user, () =>
|
space.event(user, () =>
|
||||||
space
|
space
|
||||||
|
|
@ -129,6 +138,15 @@ const stampLabel = (page: Page, seconds: number) =>
|
||||||
seconds,
|
seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// What this user's client has written to disk, of one kind. Events reach indexeddb in three-second
|
||||||
|
// batches with nothing in the ui to say when one has landed, so a spec that takes the relay away
|
||||||
|
// and reloads has to read the cache first — otherwise it passes or fails on the batch window rather
|
||||||
|
// than on what was persisted.
|
||||||
|
const cachedContent = async (page: Page, pubkey: string, kind: number) =>
|
||||||
|
(await readCachedEvents(page, pubkey))
|
||||||
|
.filter(event => event.kind === kind)
|
||||||
|
.map(event => event.content)
|
||||||
|
|
||||||
const topOf = async (locator: Locator) => {
|
const topOf = async (locator: Locator) => {
|
||||||
const box = await locator.boundingBox()
|
const box = await locator.boundingBox()
|
||||||
|
|
||||||
|
|
@ -652,3 +670,86 @@ test("US-108 read messages from a relay you only use for messages", async ({seed
|
||||||
await expect(pageBar(page)).toContainText("Bob Barnacle")
|
await expect(pageBar(page)).toContainText("Bob Barnacle")
|
||||||
await expect(bubble(page, "over on your inbox relay")).toBeVisible()
|
await expect(bubble(page, "over on your inbox relay")).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("US-109 keep a conversation you have already read", async ({seed, as}) => {
|
||||||
|
let his!: SeededRumor
|
||||||
|
let hers!: SeededRumor
|
||||||
|
|
||||||
|
const scenario = await seed(({relay, user, at}) => {
|
||||||
|
const space = relay("space")
|
||||||
|
|
||||||
|
space.room("general", {name: "General"})
|
||||||
|
|
||||||
|
seedPerson(space, user.alice, "Alice Anchor", "general")
|
||||||
|
seedPerson(space, user.bob, "Bob Barnacle", "general")
|
||||||
|
enableDms(space, user.alice)
|
||||||
|
enableDms(space, user.bob)
|
||||||
|
|
||||||
|
his = space.dm(user.bob, [user.alice], "the tide charts are up", at(2, HOUR))
|
||||||
|
hers = space.dm(user.alice, [user.bob], "thakns", at(1, HOUR))
|
||||||
|
|
||||||
|
// The control on the relay having really forgotten. A room message comes off the same relay as
|
||||||
|
// the wraps and is the kind of thing a client reads back off the wire every time, so it is what
|
||||||
|
// a conversation that survives is being distinguished from.
|
||||||
|
space.message(user.bob, "general", "boat is in the water", at(2, HOUR))
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = scenario.space("space").url
|
||||||
|
|
||||||
|
// A touch context, which is what puts Edit Message behind a named button. Editing is the only way
|
||||||
|
// the ui takes a direct message back, and the delete it publishes is the second half of the story.
|
||||||
|
const alice = await as(users.alice, roomPath(url, "general"), {context: {hasTouch: true}})
|
||||||
|
|
||||||
|
await expect(alice.locator(".room__item").filter({hasText: "boat is in the water"})).toBeVisible()
|
||||||
|
|
||||||
|
await alice.goto(chatPath(users.bob.pubkey))
|
||||||
|
|
||||||
|
await expect(message(alice, his.id)).toBeVisible()
|
||||||
|
await expect(message(alice, hers.id)).toBeVisible()
|
||||||
|
|
||||||
|
await openMessageMenu(alice, hers.id)
|
||||||
|
await alice.getByRole("button", {name: "Edit Message"}).click()
|
||||||
|
|
||||||
|
await expect(composer(alice)).toContainText("thakns")
|
||||||
|
|
||||||
|
await composer(alice).press("ControlOrMeta+a")
|
||||||
|
await send(alice, "thanks")
|
||||||
|
|
||||||
|
await expect(bubble(alice, "thanks")).toBeVisible()
|
||||||
|
await expect(alice.locator(".chat-bubble").filter({hasText: "thakns"})).toHaveCount(0)
|
||||||
|
|
||||||
|
// A reaction travels to the conversation gift-wrapped the way the messages do, so it is kept or
|
||||||
|
// lost with them rather than on its own terms.
|
||||||
|
await openMessageMenu(alice, his.id)
|
||||||
|
await alice.getByRole("button", {name: "Send Reaction"}).click()
|
||||||
|
|
||||||
|
const picker = alice.locator("emoji-picker").filter({visible: true})
|
||||||
|
|
||||||
|
await picker.locator("input.search").fill("party popper")
|
||||||
|
await picker.getByRole("option", {name: /party popper/}).click()
|
||||||
|
|
||||||
|
await expect(message(alice, his.id)).toContainText("🎉")
|
||||||
|
|
||||||
|
// The edit, the delete that retracted the original and the reaction all reach disk in batches, so
|
||||||
|
// waiting for them there is waiting for the whole of what the reload is about to read back.
|
||||||
|
await expect
|
||||||
|
.poll(() => cachedContent(alice, users.alice.pubkey, DIRECT_MESSAGE))
|
||||||
|
.toEqual(expect.arrayContaining(["the tide charts are up", "thanks"]))
|
||||||
|
|
||||||
|
await expect.poll(() => cachedContent(alice, users.alice.pubkey, REACTION)).toContain("🎉")
|
||||||
|
|
||||||
|
// Her messaging relay drops everything it was holding, and she comes back to the app
|
||||||
|
forgetRelay(alice.context(), url)
|
||||||
|
|
||||||
|
await alice.reload()
|
||||||
|
|
||||||
|
await expect(bubble(alice, "the tide charts are up")).toBeVisible()
|
||||||
|
await expect(bubble(alice, "thanks")).toBeVisible()
|
||||||
|
await expect(alice.locator(".chat-bubble").filter({hasText: "thakns"})).toHaveCount(0)
|
||||||
|
await expect(message(alice, his.id)).toContainText("🎉")
|
||||||
|
|
||||||
|
// ...and the room, which she keeps no copy of, is as empty as the relay now is
|
||||||
|
await alice.goto(roomPath(url, "general"))
|
||||||
|
|
||||||
|
await expect(alice.locator(".room__item")).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,10 @@ import {
|
||||||
ROOM_DELETE,
|
ROOM_DELETE,
|
||||||
ROOM_REMOVE_MEMBER,
|
ROOM_REMOVE_MEMBER,
|
||||||
ROOMS,
|
ROOMS,
|
||||||
|
DELETE,
|
||||||
|
REACTION,
|
||||||
hexTags,
|
hexTags,
|
||||||
|
isSignedEvent,
|
||||||
tagValues,
|
tagValues,
|
||||||
verifiedSymbol,
|
verifiedSymbol,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
|
|
@ -45,6 +48,7 @@ import {Handles, Plaintext, Relays, RelayStats, User, Zappers} from "@welshman/a
|
||||||
import type {AppPolicy, IApp, RelayStatsItem} from "@welshman/app"
|
import type {AppPolicy, IApp, RelayStatsItem} from "@welshman/app"
|
||||||
import {IDB} from "@lib/indexeddb"
|
import {IDB} from "@lib/indexeddb"
|
||||||
import {appPolicies} from "@app/core"
|
import {appPolicies} from "@app/core"
|
||||||
|
import {DM_KINDS} from "@app/content"
|
||||||
|
|
||||||
export const kv = call(() => {
|
export const kv = call(() => {
|
||||||
const enqueue = makeQueue()
|
const enqueue = makeQueue()
|
||||||
|
|
@ -153,10 +157,17 @@ const isRelayScoped = (event: TrustedEvent) =>
|
||||||
const isMembershipChange = (event: TrustedEvent) =>
|
const isMembershipChange = (event: TrustedEvent) =>
|
||||||
event.kind === ROOM_ADD_MEMBER || event.kind === ROOM_REMOVE_MEMBER
|
event.kind === ROOM_ADD_MEMBER || event.kind === ROOM_REMOVE_MEMBER
|
||||||
|
|
||||||
|
const isConversation = (event: TrustedEvent) =>
|
||||||
|
DM_KINDS.includes(event.kind) ||
|
||||||
|
([DELETE, REACTION].includes(event.kind) && !isSignedEvent(event))
|
||||||
|
|
||||||
const shouldPersistEvent = (event: TrustedEvent, pubkey: string) =>
|
const shouldPersistEvent = (event: TrustedEvent, pubkey: string) =>
|
||||||
isMembershipChange(event)
|
isMembershipChange(event)
|
||||||
? tagValues(hexTags("p"), event.tags).includes(pubkey)
|
? tagValues(hexTags("p"), event.tags).includes(pubkey)
|
||||||
: kinds.meta.includes(event.kind) || kinds.alert.includes(event.kind) || isRelayScoped(event)
|
: kinds.meta.includes(event.kind) ||
|
||||||
|
kinds.alert.includes(event.kind) ||
|
||||||
|
isRelayScoped(event) ||
|
||||||
|
isConversation(event)
|
||||||
|
|
||||||
type EventItem = {id: string; event: TrustedEvent; relays: string[]}
|
type EventItem = {id: string; event: TrustedEvent; relays: string[]}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue