flotilla/e2e/specs/dms.spec.ts

655 lines
26 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 {MessagingRelayList, RelayList} from "@welshman/domain"
import {expect, makeTestUser, test, users} from "../harness"
import type {SeededRumor, SeededSpace, TestUser} from "../harness"
// The path the app builds for a conversation: the other participants' pubkeys, sorted and joined
// with commas — see makeChatId in src/app/chats.ts.
const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}`
const profilePath = (pubkey: string) => `/people/${npubEncode(pubkey)}`
// A path as a pattern, for a url that carries a modal's hash alongside it.
const pathPattern = (path: string) => new RegExp(path.replace(/[.?+*()[\]]/g, "\\$&"))
// Everything about a person is resolved through their relay list — their profile, their messaging
// relays, the wraps addressed to them — and a scenario with no fallbacks resolves an author with
// 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
// kind-1059 by the member named in its p tag.
const seedPerson = (space: SeededSpace, user: TestUser, name: string) => {
space.join(user)
space.profile(user, {name})
space.event(user, () =>
space
.kind(RelayList)
.writer()
.setReadUrls([space.url])
.setWriteUrls([space.url])
.renderTemplate(),
)
}
// The kind-10050 ChatEnable publishes when someone accepts "Enable direct messaging?". Having one
// is what makes a person reachable, so a story about messaging being off is a person without one.
const enableDms = (space: SeededSpace, user: TestUser) =>
space.event(user, () =>
space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(),
)
const chatList = (page: Page) => page.locator(".secondary-nav .overflow-auto")
// One ChatItem is one button; nothing inside it is one.
const chatItems = (page: Page) => chatList(page).locator("button")
const chatFilter = (page: Page) => page.locator(".secondary-nav input[type='text']")
// ChatItem's unread mark is a bare dot with no text of its own.
const unreadDots = (scope: Locator) => scope.locator(".rounded-full.bg-primary")
const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
// 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})})
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
// The send button is named by the shortcut it advertises, which differs by platform.
const sendButton = (page: Page) => page.locator('.room__compose [data-tip$="enter to send"]')
// Both things the composer puts above itself — the message being replied to and the editing
// indicator — are the same bordered strip.
const composePreview = (page: Page) => page.locator(".room__compose .border-l-2")
const bubble = (page: Page, text: string) => page.locator(".chat-bubble").filter({hasText: text})
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, so waiting
// on it is part of sending rather than a wait for a wait's sake.
const send = async (page: Page, content: string) => {
await expect(sendButton(page)).toBeEnabled()
await composer(page).pressSequentially(content)
await composer(page).press("Enter")
}
// A suggestion carries the pubkey it selects as its label, so the name is what gets typed and the
// pubkey is what identifies the row that comes back.
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}"]`)
// Typed a keystroke at a time rather than filled: the suggestion list is rebuilt off the term
// changing, so a term that arrives all at once is matched against whatever the profile search
// held at that instant and never again.
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 live behind a hover popover on a pointer device and behind a modal of named
// buttons on a touch one, so a spec that wants to name them opens a touch context.
const openMessageMenu = (page: Page, id: string) =>
message(page, id).locator(".chat-bubble").click()
// A day divider and a chat item's stamp, formatted by the browser rather than by node, so the
// locale and the timezone are the ones the app rendered with. The options mirror dateFormatter and
// dateTimeFormatter in @welshman/lib.
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,
)
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")
enableDms(space, user.alice)
enableDms(space, user.bob)
// A conversation the three of them are already in. It puts bob's profile in her client before
// she goes looking for him, without being the one-on-one she is about to start.
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, so his name
// rather than his npub in its header is the client saying it has his profile, which is what the
// search she is about to type into is built from.
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 expect(sendButton(page)).toBeEnabled()
await send(page, "hi bob")
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()
// 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")
enableDms(space, user.alice)
enableDms(space, user.bob)
enableDms(space, user.carol)
// 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, which is what
// the search she is about to type into is built from.
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 expect(sendButton(page)).toBeEnabled()
// 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 expect(sendButton(alice)).toBeDisabled()
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 expect(sendButton(alice)).toBeEnabled()
await expect(banner).toHaveCount(0)
await send(alice, "finally")
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")
enableDms(space, user.alice)
enableDms(space, user.bob)
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 send(alice, "just got here")
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")
enableDms(space, user.alice)
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")
enableDms(space, user.alice)
enableDms(space, user.bob)
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 send(bob, "are you free later?")
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").click()
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")
enableDms(space, user.alice)
enableDms(space, user.bob)
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 is what puts a message's actions behind named buttons rather than
// behind a row of icons in a hover popover.
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()
// Reply: the preview appears above the composer, and closing it sends nothing
await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "Send Reply"}).click()
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)
await alice.getByRole("button", {name: "Send Reply"}).click()
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
await send(alice, "yes I did")
// 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?")
// Edit: her own message is replaced in place for both of them rather than duplicated
await openMessageMenu(alice, hers.id)
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 send(alice, "second attempt")
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)
await alice.getByRole("button", {name: "Send Reaction"}).click()
const picker = alice.locator("emoji-picker").filter({visible: true})
// A result's label is the emoji, its annotation and every shortcode joined together, so the
// annotation is what gets matched rather than the whole of it.
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")
enableDms(space, user.alice)
enableDms(space, user.bob)
// A conversation each of them already has. A list with something in it is how each page says
// its own end of the sync is up, before the one that has to arrive live is sent.
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 send(bob, "starting a chat with you")
// 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-108 read messages from a relay you only use for messages", async ({seed, as}) => {
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")
enableDms(space, user.bob)
// Alice's inbox is a relay she has nothing else to do with: not a space she has joined, and
// not one of her read or write relays. Membership of it is only what lets a wrap addressed to
// her be stored there — it never reaches her room list — so her messaging relay list is the
// one thing that can vouch for her, and the relay serves her nothing until it does.
inbox.member(user.alice)
// Tagged verbatim rather than through setUrls, which normalizes on the way in. A list written
// by another client is where a url missing its trailing slash comes from, and the relay it
// names is the same relay either way.
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))
})
const page = await as(users.alice, "/chat")
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()
})