flotilla/e2e/specs/dms.spec.ts

804 lines
31 KiB
TypeScript
Raw Normal View History

2026-08-20 20:33:20 +00:00
import {npubEncode} from "nostr-tools/nip19"
import type {Locator, Page} from "@playwright/test"
import {DAY, HOUR, MINUTE} from "@welshman/lib"
import {DIRECT_MESSAGE, REACTION} from "@welshman/util"
import {FollowList, MessagingRelayList} from "@welshman/domain"
import {
bubble,
chatItems,
chatList,
chatPath,
composer,
composerDisabled,
composerEnabled,
expect,
forgetRelay,
makeTestUser,
pageBar,
pathPattern,
profilePath,
readCachedEvents,
roomPath,
send,
test,
topDialog,
users,
} from "../harness"
2026-08-20 20:33:20 +00:00
import type {SeededRumor, SeededSpace, TestUser} from "../harness"
// A person here is a membership, a profile and a relay list, since everything resolves through one.
const seedPerson = (space: SeededSpace, user: TestUser, name: string, ...rooms: string[]) => {
space.join(user, ...rooms)
2026-08-20 20:33:20 +00:00
space.profile(user, {name})
space.relayList(user)
2026-08-20 20:33:20 +00:00
}
const chatFilter = (page: Page) => page.locator(".secondary-nav input[type='text']")
2026-09-21 20:57:07 +00:00
// The chat list renders twice, once per breakpoint, so this scopes the tab to the sidebar.
const chatTab = (page: Page, name: string) =>
page.locator(".secondary-nav").getByRole("button", {name})
2026-08-20 20:33:20 +00:00
// ChatItem's unread mark is a bare dot with no text of its own.
const unreadDots = (scope: Locator) => scope.locator(".rounded-full.bg-primary")
// The only ghost circle button either the profile page or the profile modal renders.
const profileMenu = (scope: Page | Locator) => scope.locator("button.button-circle.button-ghost")
2026-08-20 20:33:20 +00:00
// A modal's body is the only scroll container carrying its title.
const modalBody = (page: Page, title: string) =>
page
.locator(".scroll-container")
.filter({has: page.getByRole("heading", {name: title, exact: true})})
// The reply preview and the editing indicator are the same bordered strip.
2026-08-20 20:33:20 +00:00
const composePreview = (page: Page) => page.locator(".room__compose .border-l-2")
// The same words are sent more than once in these stories, so only the id says which bubble is which.
2026-08-20 20:33:20 +00:00
const message = (page: Page, id: string) => page.locator(`[data-event="${id}"]`)
const enablePrompt = (page: Page) => page.getByRole("heading", {name: "Enable direct messaging?"})
// The composer stays disabled until every recipient's messaging relays have been read.
const sendDm = async (page: Page, content: string) => {
await composerEnabled(page)
await send(page, content)
2026-08-20 20:33:20 +00:00
}
// A suggestion carries the pubkey it selects as its label, so the pubkey identifies the row.
2026-08-20 20:33:20 +00:00
const startChat = async (page: Page, people: {term: string; user: TestUser}[]) => {
await page.getByRole("button", {name: "Start New Chat"}).click()
await expect(page.getByRole("heading", {name: "Start a Chat"})).toBeVisible()
for (const {term, user} of people) {
const suggestion = page.locator(`.tiptap-suggestions__item[aria-label="${user.pubkey}"]`)
// The suggestion list is rebuilt off the term changing, so a term arriving at once is matched once.
2026-08-20 20:33:20 +00:00
await page.getByPlaceholder("Search for profiles...").pressSequentially(term, {delay: 150})
await expect(suggestion).toContainText(term)
await suggestion.click()
}
await page.getByRole("button", {name: "Create Chat"}).click()
}
// A message's actions are a hover popover on a pointer device and named buttons on a touch one.
2026-08-20 20:33:20 +00:00
const openMessageMenu = (page: Page, id: string) =>
message(page, id).locator(".chat-bubble").click()
// Formatted by the browser rather than by node. The options mirror dateFormatter in @welshman/lib.
2026-08-20 20:33:20 +00:00
const dayLabel = (page: Page, seconds: number) =>
page.evaluate(
ts =>
new Intl.DateTimeFormat(undefined, {year: "numeric", month: "long", day: "numeric"}).format(
new Date(ts * 1000),
),
seconds,
)
const stampLabel = (page: Page, seconds: number) =>
page.evaluate(
ts =>
new Intl.DateTimeFormat(undefined, {dateStyle: "short", timeStyle: "short"}).format(
new Date(ts * 1000),
),
seconds,
)
// Events reach indexeddb in three-second batches, so a spec that reloads has to read the cache first.
const cachedContent = async (page: Page, pubkey: string, kind: number) =>
(await readCachedEvents(page, pubkey))
.filter(event => event.kind === kind)
.map(event => event.content)
2026-08-20 20:33:20 +00:00
const topOf = async (locator: Locator) => {
const box = await locator.boundingBox()
expect(box).toBeTruthy()
return box!.y
}
test("US-029 start a one-on-one chat", async ({seed, as}) => {
await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter")
space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob)
2026-08-20 20:33:20 +00:00
// A conversation the three of them are already in, which puts bob's profile in her client.
2026-08-20 20:33:20 +00:00
space.dm(user.carol, [user.alice, user.bob], "welcome aboard, both of you", at(3, HOUR))
})
const page = await as(users.alice, "/chat")
// A group conversation's item in the list names only the first of its participants.
2026-08-20 20:33:20 +00:00
await chatItems(page).filter({hasText: "welcome aboard"}).click()
await expect(pageBar(page)).toContainText("Bob Barnacle")
await startChat(page, [{term: "Barnacle", user: users.bob}])
await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
await expect(composer(page)).toBeVisible()
await composerEnabled(page)
2026-08-20 20:33:20 +00:00
await sendDm(page, "hi bob")
2026-08-20 20:33:20 +00:00
await expect(bubble(page, "hi bob")).toBeVisible()
// The conversation she just started is the one at the top of her list, under his name
await expect(chatItems(page).first()).toContainText("Bob Barnacle")
await expect(chatItems(page).first()).toContainText("hi bob")
// His profile leads back into the same conversation
await pageBar(page).getByRole("button", {name: "Bob Barnacle"}).click()
await page.getByRole("button", {name: "View Full Profile"}).click()
await expect(page).toHaveURL(pathPattern(profilePath(users.bob.pubkey)))
await page.getByRole("button", {name: "Message", exact: true}).click()
await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
await expect(bubble(page, "hi bob")).toBeVisible()
// The profile page's menu leaves messaging to the button beside it
await page.goto(profilePath(users.bob.pubkey))
await profileMenu(page).click()
await expect(page.getByRole("button", {name: "Profile Info"})).toBeVisible()
await expect(page.getByRole("button", {name: "Send Message"})).toHaveCount(0)
// The profile modal, which has no such button, carries the action in its menu instead
await page.goto("/chat")
await chatItems(page).filter({hasText: "welcome aboard"}).click()
await bubble(page, "welcome aboard").getByRole("button", {name: "Carol Cutter"}).first().click()
await profileMenu(topDialog(page)).click()
await page.getByRole("button", {name: "Send Message"}).click()
await expect(page).toHaveURL(pathPattern(chatPath(users.carol.pubkey)))
await expect(pageBar(page)).toContainText("Carol Cutter")
2026-08-20 20:33:20 +00:00
// Her own profile offers no way to message herself
await page.goto(profilePath(users.alice.pubkey))
await expect(page.getByRole("button", {name: "Edit profile"})).toBeVisible()
await expect(page.getByRole("button", {name: "Message", exact: true})).toHaveCount(0)
})
test("US-030 start a group chat", async ({seed, as}) => {
await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter")
space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob)
space.messagingRelayList(user.carol)
2026-08-20 20:33:20 +00:00
// One conversation with each of them, so both profiles are in her client before she searches.
space.dm(user.bob, [user.alice], "just us two", at(4, HOUR))
space.dm(user.carol, [user.alice], "hello from carol", at(3, HOUR))
})
const page = await as(users.alice, "/chat")
// Their names rather than their npubs is the client saying it has both profiles.
2026-08-20 20:33:20 +00:00
await expect(chatItems(page).filter({hasText: "just us two"})).toContainText("Bob Barnacle")
await expect(chatItems(page).filter({hasText: "hello from carol"})).toContainText("Carol Cutter")
await startChat(page, [
{term: "Barnacle", user: users.bob},
{term: "Cutter", user: users.carol},
])
// One conversation holding both of them, rather than one apiece
await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey, users.carol.pubkey)))
await composerEnabled(page)
2026-08-20 20:33:20 +00:00
// Labeled with both of them rather than with one
await expect(pageBar(page)).toContainText("Bob Barnacle")
await expect(pageBar(page)).toContainText("Carol Cutter")
await pageBar(page)
.getByRole("button", {name: /Bob Barnacle/})
.click()
const members = modalBody(page, "People in this conversation")
await expect(members.getByText("Bob Barnacle")).toBeVisible()
await expect(members.getByText("Carol Cutter")).toBeVisible()
await members.getByRole("button", {name: "Carol Cutter"}).click()
await expect(members).toHaveCount(0)
await expect(page.getByRole("button", {name: "View Full Profile"})).toBeVisible()
await page.getByRole("button", {name: "View Full Profile"}).click()
await expect(page).toHaveURL(pathPattern(profilePath(users.carol.pubkey)))
})
test("US-031 direct messaging has to be switched on", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
// Neither of them has ever published a kind-10050, which is what messaging being off is.
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
})
const alice = await as(users.alice, profilePath(users.bob.pubkey))
await alice.getByRole("button", {name: "Message", exact: true}).click()
// The prompt stands where the conversation she asked for would have been
await expect(enablePrompt(alice)).toBeVisible()
await expect(alice).not.toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
await expect(composer(alice)).toHaveCount(0)
await alice.getByRole("button", {name: "Enable direct messaging"}).click()
// Turning it on carries her on into the conversation she started
await expect(enablePrompt(alice)).toHaveCount(0)
await expect(alice).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
// Bob has never enabled it, so his end of the conversation is a dead end
const banner = alice.locator(".card").filter({hasText: "Direct messages are not enabled"})
await expect(banner).toContainText("Bob Barnacle")
await composerDisabled(alice)
2026-08-20 20:33:20 +00:00
const bob = await as(users.bob, "/")
await bob.locator('.primary-nav [data-tip="Messages"]').click()
await expect(enablePrompt(bob)).toBeVisible()
await bob.getByRole("button", {name: "Enable direct messaging"}).click()
// His list is the app's evidence that his kind-10050 has been published
await expect(bob).toHaveURL(/\/chat$/)
await expect(bob.getByRole("button", {name: "Start New Chat"})).toBeVisible()
// She reopens the conversation the way she opened it the first time
await pageBar(alice).getByRole("button", {name: "Bob Barnacle"}).click()
await alice.getByRole("button", {name: "View Full Profile"}).click()
await expect(alice).toHaveURL(pathPattern(profilePath(users.bob.pubkey)))
await alice.getByRole("button", {name: "Message", exact: true}).click()
// The prompt is behind her for good, and his end of it works now
await expect(alice).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
await expect(enablePrompt(alice)).toHaveCount(0)
await composerEnabled(alice)
2026-08-20 20:33:20 +00:00
await expect(banner).toHaveCount(0)
await sendDm(alice, "finally")
2026-08-20 20:33:20 +00:00
await expect(bubble(alice, "finally")).toBeVisible()
await expect(chatItems(bob).filter({hasText: "finally"})).toBeVisible()
})
test("US-032 exchange messages in a conversation", async ({seed, as}) => {
let yesterday!: SeededRumor
let firstToday!: SeededRumor
let secondToday!: SeededRumor
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob)
2026-08-20 20:33:20 +00:00
yesterday = space.dm(user.bob, [user.alice], "sent this yesterday", at(30, HOUR))
// A minute apart, which is what puts these two in the same run of messages.
firstToday = space.dm(user.bob, [user.alice], "morning alice", at(50, MINUTE))
secondToday = space.dm(user.bob, [user.alice], "still around?", at(49, MINUTE))
})
const alice = await as(users.alice, chatPath(users.bob.pubkey))
const bob = await as(users.bob, chatPath(users.alice.pubkey))
await expect(message(alice, yesterday.id)).toBeVisible()
await expect(message(alice, firstToday.id)).toBeVisible()
await expect(message(alice, secondToday.id)).toBeVisible()
// Chronological: each message is rendered below the one before it
expect(await topOf(message(alice, yesterday.id))).toBeLessThan(
await topOf(message(alice, firstToday.id)),
)
expect(await topOf(message(alice, firstToday.id))).toBeLessThan(
await topOf(message(alice, secondToday.id)),
)
// A divider per day
await expect(alice.getByText(await dayLabel(alice, scenario.at(30, HOUR)))).toBeVisible()
await expect(alice.getByText(await dayLabel(alice, scenario.at(50, MINUTE)))).toBeVisible()
// His second message in a row carries neither his name nor his avatar
await expect(bubble(alice, "morning alice")).toContainText("Bob Barnacle")
await expect(bubble(alice, "morning alice").locator(".rounded-full")).toHaveCount(1)
await expect(bubble(alice, "still around?")).not.toContainText("Bob Barnacle")
await expect(bubble(alice, "still around?").locator(".rounded-full")).toHaveCount(0)
await sendDm(alice, "just got here")
2026-08-20 20:33:20 +00:00
await expect(bubble(alice, "just got here")).toBeVisible()
await expect(bubble(alice, "just got here")).toHaveClass(/chat-bubble--user/)
await expect(composer(alice)).toHaveText("")
// ...and it reaches the conversation he already has open, attributed to her
await expect(bubble(bob, "just got here")).toBeVisible()
await expect(bubble(bob, "just got here")).toContainText("Alice Anchor")
})
test("US-033 browse and search your conversations", async ({seed, as}) => {
const dave = makeTestUser("dave")
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter")
seedPerson(space, dave, "Dave Davits")
space.messagingRelayList(user.alice)
2026-08-20 20:33:20 +00:00
space.dm(dave, [user.alice], "see you at the meetup", at(2, DAY))
space.dm(user.carol, [user.alice], "thanks for the link", at(3, HOUR))
space.dm(user.bob, [user.alice], "lunch tomorrow?", at(30, MINUTE))
})
const page = await as(users.alice, "/chat")
await expect(chatItems(page)).toHaveCount(3)
const bobStamp = await stampLabel(page, scenario.at(30, MINUTE))
const carolStamp = await stampLabel(page, scenario.at(3, HOUR))
const daveStamp = await stampLabel(page, scenario.at(2, DAY))
// Most recently active first, each with a name, a preview and a timestamp
await expect(chatItems(page).nth(0)).toContainText("Bob Barnacle")
await expect(chatItems(page).nth(0)).toContainText("lunch tomorrow?")
await expect(chatItems(page).nth(0)).toContainText(bobStamp)
await expect(chatItems(page).nth(1)).toContainText("Carol Cutter")
await expect(chatItems(page).nth(1)).toContainText("thanks for the link")
await expect(chatItems(page).nth(1)).toContainText(carolStamp)
await expect(chatItems(page).nth(2)).toContainText("Dave Davits")
await expect(chatItems(page).nth(2)).toContainText("see you at the meetup")
await expect(chatItems(page).nth(2)).toContainText(daveStamp)
await chatFilter(page).fill("Bob Barnacle")
await expect(chatItems(page)).toHaveCount(1)
await expect(chatItems(page).nth(0)).toContainText("Bob Barnacle")
await chatFilter(page).fill("")
await expect(chatItems(page)).toHaveCount(3)
})
test("US-034 track and clear unread conversations", async ({seed, as}) => {
const dave = makeTestUser("dave")
await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter")
seedPerson(space, dave, "Dave Davits")
space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob)
2026-08-20 20:33:20 +00:00
space.dm(user.carol, [user.alice], "did you see this?", at(3, HOUR))
space.dm(dave, [user.alice], "ping from dave", at(2, HOUR))
})
const alice = await as(users.alice, "/chat")
const bobChat = chatItems(alice).filter({hasText: "Bob Barnacle"})
const carolChat = chatItems(alice).filter({hasText: "Carol Cutter"})
const daveChat = chatItems(alice).filter({hasText: "Dave Davits"})
await expect(chatItems(alice)).toHaveCount(2)
await expect(unreadDots(carolChat)).toBeVisible()
await expect(unreadDots(daveChat)).toBeVisible()
// Bob writes while she is looking at the list rather than at his conversation
const bob = await as(users.bob, chatPath(users.alice.pubkey))
await sendDm(bob, "are you free later?")
2026-08-20 20:33:20 +00:00
await expect(bobChat).toBeVisible()
await expect(unreadDots(bobChat)).toBeVisible()
// Reading it and moving on clears it
await bobChat.click()
await expect(bubble(alice, "are you free later?")).toBeVisible()
await carolChat.click()
await expect(bubble(alice, "did you see this?")).toBeVisible()
await expect(unreadDots(bobChat)).toHaveCount(0)
// Mark all read clears what is left of them in one go
await alice.locator(".secondary-nav__header").getByRole("button", {name: "Chat options"}).click()
2026-08-20 20:33:20 +00:00
await alice.getByRole("button", {name: "Mark all read"}).click()
await expect(unreadDots(chatList(alice))).toHaveCount(0)
// ...and they are still clear when she comes back
await alice.locator('.primary-nav [data-tip="Settings"]').click()
await expect(alice).toHaveURL(pathPattern("/settings/profile"))
await alice.locator('.primary-nav [data-tip="Messages"]').click()
await expect(chatItems(alice)).toHaveCount(3)
await expect(unreadDots(chatList(alice))).toHaveCount(0)
})
test("US-035 reply to, edit, and react to a direct message", async ({seed, as}) => {
let his!: SeededRumor
let hers!: SeededRumor
await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob)
2026-08-20 20:33:20 +00:00
his = space.dm(user.bob, [user.alice], "did you see the thing?", at(20, MINUTE))
hers = space.dm(user.alice, [user.bob], "first attempt", at(10, MINUTE))
})
// A touch context, which puts a message's actions behind named buttons rather than a hover popover.
2026-08-20 20:33:20 +00:00
const alice = await as(users.alice, chatPath(users.bob.pubkey), {context: {hasTouch: true}})
const bob = await as(users.bob, chatPath(users.alice.pubkey))
await expect(message(alice, his.id)).toBeVisible()
await expect(message(alice, hers.id)).toBeVisible()
const desktopActions = message(bob, his.id).getByRole("button", {name: "Message actions"})
await desktopActions.focus()
await expect(desktopActions).toHaveCSS("opacity", "1")
await desktopActions.click()
await expect(bob.getByRole("button", {name: "Add a reaction"})).toBeVisible()
await expect(bob.getByRole("button", {name: "Reply"})).toBeVisible()
await expect(bob.getByRole("button", {name: "Message info"})).toBeVisible()
await bob.keyboard.press("Escape")
await expect(desktopActions).toBeFocused()
2026-08-20 20:33:20 +00:00
// Reply: the preview appears above the composer, and closing it sends nothing
await openMessageMenu(alice, his.id)
2026-09-09 01:07:48 +00:00
await alice.getByRole("button", {name: "Reply"}).click()
2026-08-20 20:33:20 +00:00
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
await expect(composePreview(alice)).toContainText("did you see the thing?")
await composePreview(alice).getByRole("button").last().click()
await expect(composePreview(alice)).toHaveCount(0)
await expect(alice.locator(".chat-bubble")).toHaveCount(2)
await openMessageMenu(alice, his.id)
2026-09-09 01:07:48 +00:00
await alice.getByRole("button", {name: "Reply"}).click()
2026-08-20 20:33:20 +00:00
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
await sendDm(alice, "yes I did")
2026-08-20 20:33:20 +00:00
// The reply that gets sent quotes what it answers
await expect(bubble(alice, "yes I did")).toContainText("did you see the thing?")
await expect(bubble(bob, "yes I did")).toContainText("did you see the thing?")
// The menu leads with React and Reply and keeps the rest behind a disclosure.
2026-08-20 20:33:20 +00:00
await openMessageMenu(alice, hers.id)
2026-09-09 01:07:48 +00:00
await alice.getByRole("button", {name: "More Options"}).click()
2026-08-20 20:33:20 +00:00
await expect(alice.getByRole("button", {name: "Edit Message"})).toBeVisible()
await alice.getByRole("button", {name: "Edit Message"}).click()
await expect(composePreview(alice)).toContainText("Editing message")
await expect(composer(alice)).toContainText("first attempt")
await composer(alice).press("ControlOrMeta+a")
await sendDm(alice, "second attempt")
2026-08-20 20:33:20 +00:00
await expect(bubble(alice, "second attempt")).toBeVisible()
await expect(alice.locator(".chat-bubble").filter({hasText: "first attempt"})).toHaveCount(0)
await expect(bubble(bob, "second attempt")).toBeVisible()
await expect(bob.locator(".chat-bubble").filter({hasText: "first attempt"})).toHaveCount(0)
// His, hers and her reply — the edit replaced one rather than adding a fourth
await expect(alice.locator(".chat-bubble")).toHaveCount(3)
await expect(bob.locator(".chat-bubble")).toHaveCount(3)
// React, then take it back, on both sides of the conversation
await openMessageMenu(alice, his.id)
2026-09-09 01:07:48 +00:00
await alice.getByRole("button", {name: "React"}).click()
2026-08-20 20:33:20 +00:00
const picker = alice.locator("emoji-picker").filter({visible: true})
// A result's label joins the emoji, its annotation and every shortcode.
2026-08-20 20:33:20 +00:00
await picker.locator("input.search").fill("party popper")
await picker.getByRole("option", {name: /party popper/}).click()
await expect(message(alice, his.id)).toContainText("🎉")
await expect(message(bob, his.id)).toContainText("🎉")
await message(alice, his.id).getByRole("button", {name: "🎉"}).click()
await expect(message(alice, his.id)).not.toContainText("🎉")
await expect(message(bob, his.id)).not.toContainText("🎉")
// Up in an empty composer picks up the last thing she said, which is the reply
await expect(composer(alice)).toHaveText("")
await composer(alice).press("ArrowUp")
await expect(composePreview(alice)).toContainText("Editing message")
await expect(composer(alice)).toContainText("yes I did")
})
test("US-036 receive a new conversation live", async ({seed, as}) => {
await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter")
space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob)
2026-08-20 20:33:20 +00:00
// A list with something in it is how each page says its own end of the sync is up.
2026-08-20 20:33:20 +00:00
space.dm(user.carol, [user.alice], "see you monday", at(4, HOUR))
space.dm(user.carol, [user.bob], "you too", at(4, HOUR))
})
const alice = await as(users.alice, "/chat")
await expect(chatItems(alice).filter({hasText: "see you monday"})).toBeVisible()
const bob = await as(users.bob, "/chat")
await expect(chatItems(bob).filter({hasText: "you too"})).toBeVisible()
// He has never talked to her, so he identifies her by the code she gave him
await bob.getByRole("button", {name: "Start New Chat"}).click()
await bob.getByPlaceholder("Search for profiles...").fill(npubEncode(users.alice.pubkey))
await bob.getByRole("button", {name: "Create Chat"}).click()
await expect(bob).toHaveURL(pathPattern(chatPath(users.alice.pubkey)))
await sendDm(bob, "starting a chat with you")
2026-08-20 20:33:20 +00:00
// His own copy first, so a send that lost keystrokes fails here rather than thirty seconds later.
await expect(bubble(bob, "starting a chat with you")).toBeVisible()
2026-08-20 20:33:20 +00:00
// Her list picks the conversation up on its own
const fromBob = chatItems(alice).filter({hasText: "Bob Barnacle"})
await expect(fromBob).toBeVisible()
await fromBob.click()
await expect(bubble(alice, "starting a chat with you")).toBeVisible()
})
test("US-128 keep messages from strangers out of your conversations", async ({seed, as}) => {
const dave = makeTestUser("dave")
const eve = makeTestUser("eve")
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
const inbox = relay("other")
// Carol she knows only through the space they are both in, the weakest thing that counts as knowing.
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter")
space.messagingRelayList(user.bob)
space.messagingRelayList(user.carol)
space.event(user.alice, () =>
space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(),
)
// Membership of a relay never reaches a room list, so writing from it vouches for nobody.
space.messagingRelayList(user.alice, [space.url, inbox.url])
inbox.member(user.alice)
inbox.member(dave)
inbox.member(eve)
space.dm(user.bob, [user.alice], "lunch tomorrow?", at(30, MINUTE))
space.dm(user.carol, [user.alice], "the hull is patched", at(1, HOUR))
inbox.dm(dave, [user.alice], "you have won a prize", at(2, HOUR))
// Eve is as much a stranger as Dave, and answering her is the whole difference between them.
inbox.dm(eve, [user.alice], "are you the alice from the boatyard?", at(4, HOUR))
inbox.dm(user.alice, [eve], "I am, who is this?", at(3, HOUR))
})
// The socket a client opens before it knows what a relay is for never identifies itself to it.
const page = await as(users.alice, "/chat", {
env: {VITE_INDEXER_RELAYS: scenario.space("space").url},
})
await expect(chatTab(page, "Conversations")).toContainText("3")
await expect(chatTab(page, "Requests")).toContainText("1")
await expect(chatItems(page)).toHaveCount(3)
await expect(chatItems(page).nth(0)).toContainText("lunch tomorrow?")
await expect(chatItems(page).nth(1)).toContainText("the hull is patched")
await expect(chatItems(page).nth(2)).toContainText("I am, who is this?")
await chatTab(page, "Requests").click()
const fromDave = chatItems(page).filter({hasText: "you have won a prize"})
await expect(chatItems(page)).toHaveCount(1)
await expect(fromDave).toBeVisible()
// A request is a chat like any other, so it opens and reads as one.
await fromDave.click()
await expect(bubble(page, "you have won a prize")).toBeVisible()
})
test("US-108 read messages from a relay you only use for messages", async ({seed, as}) => {
2026-09-09 01:07:48 +00:00
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
const inbox = relay("other")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
space.messagingRelayList(user.bob)
// Her messaging relay list is the one thing that vouches for her, and the relay serves her nothing until it does.
inbox.member(user.alice)
// This relay authorizes a wrap by the member its p tag names, so bob has to be one too.
2026-09-09 01:07:48 +00:00
inbox.member(user.bob)
// Tagged verbatim rather than through setUrls, which normalizes on the way in.
space.event(user.alice, () =>
space
.kind(MessagingRelayList)
.writer()
.addTags(["relay", inbox.url.replace(/\/$/, "")])
.renderTemplate(),
)
inbox.dm(user.bob, [user.alice], "over on your inbox relay", at(2, HOUR))
})
// Only her space indexes, so the inbox relay is dialled for her messages and nothing else.
2026-09-09 01:07:48 +00:00
const page = await as(users.alice, "/chat", {
env: {VITE_INDEXER_RELAYS: scenario.space("space").url},
})
const conversation = chatItems(page).filter({hasText: "over on your inbox relay"})
await expect(conversation).toContainText("Bob Barnacle")
await conversation.click()
await expect(pageBar(page)).toContainText("Bob Barnacle")
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")
space.messagingRelayList(user.alice)
space.messagingRelayList(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))
// A room message comes off the same relay and is read back off the wire every time.
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.
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)
2026-09-09 01:07:48 +00:00
await alice.getByRole("button", {name: "More Options"}).click()
await alice.getByRole("button", {name: "Edit Message"}).click()
await expect(composer(alice)).toContainText("thakns")
await composer(alice).press("ControlOrMeta+a")
await sendDm(alice, "thanks")
await expect(bubble(alice, "thanks")).toBeVisible()
await expect(alice.locator(".chat-bubble").filter({hasText: "thakns"})).toHaveCount(0)
// A reaction travels gift-wrapped the way the messages do, so it is kept or lost with them.
await openMessageMenu(alice, his.id)
2026-09-09 01:07:48 +00:00
await alice.getByRole("button", {name: "React"}).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 and the reaction all reach disk in batches, which is what the reload reads.
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)
})