import {neventEncode, npubEncode} from "nostr-tools/nip19" import {HOUR, MINUTE} from "@welshman/lib" import {displayRelayUrl} from "@welshman/util" import {MessagingRelayList, RelayList} from "@welshman/domain" import type {Locator, Page} from "@playwright/test" import {expect, roomPath, spacePath, test, users} from "../harness" import type {SeededSpace, TestUser} from "../harness" // A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id is ever // read back here. type Seeded = {readonly id: string} // A literal as a pattern, for a url that carries a query string alongside the path being matched, // or a host whose dots would otherwise be wildcards. const pattern = (literal: string) => new RegExp(literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) // The unread indicator, which every surface renders the same way: a small primary-colored dot in // the corner of the thing it belongs to. RelaySummary's "you're a member" check is the same shape // at h-5 w-5, so the size is part of what says which one this is. const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary") // The bell SpaceMenuRoomItem hangs off a room whose notification setting differs from its space's. // An icon is a css mask built from a data url, so which bell it is can't be read out of the class // list — but it only renders on a difference, and while the space itself is still notifying the // only difference a room can have is a mute. const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50") // The bell SpaceMenuHeader puts beside the space's name once the space itself is muted. The only // other absolutely-positioned dot in that button is the admin action-items one, which is opacity-0. const mutedSpaceBell = (header: Locator) => header.locator("div.opacity-50") // FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row, with the // label at one end and the control at the other. const settingRow = (page: Page, label: string) => page.locator("div.items-center.justify-between").filter({hasText: label}) // PrimaryNavItemSpace carries the relay's name as a tooltip rather than as an accessible name — its // icon is a masked svg with no alt text — and it has an onclick, so PrimaryNavItem renders it as a // button rather than a link. The name comes from nip-11; until that document lands the tooltip is // the relay's host instead, and each tenant's toml names it after itself, so both start the same. const spaceNavItem = (page: Page, name: string) => page.locator(`.primary-nav [data-tip^="${name}"]`) // The desktop rail and the phone's bottom bar each carry a link to the space list, and neither one // has an accessible name — both icons are masked svgs. Below tailwind's md breakpoint the rail is // display:none, so on a phone the visible one is the bar's. const spacesNavItem = (page: Page) => page.locator('a[href="/spaces"]:visible') // The space menu's header, the one button in the secondary nav carrying the relay's address. const spaceMenu = (page: Page, url: string) => page.locator(".secondary-nav").getByRole("button", {name: pattern(displayRelayUrl(url))}) const roomLink = (page: Page, name: string) => page.locator(".space-menu__scroll").getByRole("link", {name}) // The room's page bar carries a back arrow (display:none at this viewport), a search button and the // detail button, in that order. const openRoomDetail = (page: Page) => page.locator('[data-component="PageBar"]').getByRole("button").last().click() // `.dialog` is on both the backdrop wrapper and the panel inside it, so the last match is the panel. const dialog = (page: Page, title: string) => page .locator(".dialog") .filter({has: page.getByRole("heading", {name: title, exact: true})}) .last() const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]") const message = (page: Page, text: string) => page.locator(".room__item").filter({hasText: text}) const send = async (page: Page, content: string) => { await composer(page).pressSequentially(content) await composer(page).press("Enter") await expect(message(page, content)).toBeVisible() } // The page bar names the room, so waiting for it is what says the composer below now belongs to // the room that was just opened rather than to the one being torn down. const postTo = async (page: Page, room: string, content: string) => { await roomLink(page, room).click() await expect(page.locator('[data-component="PageBar"]')).toContainText(room) await send(page, content) } // Outbox routing resolves everything about a person through their relay list: settings are // published to the relays it names, and a gift wrap only reaches somebody who has said where their // messages go. const seedRelays = (space: SeededSpace, user: TestUser) => { space.event(user, () => space .kind(RelayList) .writer() .setReadUrls([space.url]) .setWriteUrls([space.url]) .renderTemplate(), ) space.event(user, () => space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(), ) } test("US-103 see and clear unread indicators", async ({seed, as}) => { const scenario = await seed(({relay, user, at}) => { const space = relay("space") space.room("general", {name: "General"}) space.room("random", {name: "Random"}) space.join(user.alice, "general", "random") space.join(user.bob, "general", "random") // History bob wrote himself, so the space has something in it while nothing in it is unread // for him — his own messages never raise an indicator. space.message(user.bob, "general", "anyone around?", at(2, HOUR)) }) const space = scenario.space("space") const bob = await as(users.bob, "/spaces") const alice = await as(users.alice, roomPath(space.url, "general")) const spaceRow = bob.getByRole("listitem").filter({hasText: space.url}) const navItem = spaceNavItem(bob, space.name) await expect(spaceRow).toBeVisible() await expect(navItem).toBeVisible() await expect(unreadDot(spaceRow)).toHaveCount(0) await expect(unreadDot(navItem)).toHaveCount(0) await send(alice, "the server is on fire") // Bob is sitting on the space list the whole time, so both dots arrive without a navigation await expect(unreadDot(spaceRow)).toBeVisible() await expect(unreadDot(navItem)).toBeVisible() // Inside the space, the dot points at the room the message landed in. A space's room list only // exists in its own menu, so this is the one indicator the list above can't show. await spaceRow.click() const general = roomLink(bob, "General") const random = roomLink(bob, "Random") // Both rooms are on screen first, so a room with no dot is a room raising none rather than a // row that never rendered await expect(random).toBeVisible() await expect(unreadDot(general)).toBeVisible() await expect(unreadDot(random)).toHaveCount(0) await general.click() await expect(message(bob, "the server is on fire")).toBeVisible() // Reading it clears the room's dot... await bob.getByRole("link", {name: "Space Details"}).click() await expect(general).toBeVisible() await expect(unreadDot(general)).toHaveCount(0) // ...and it stays cleared back on the space list he started from await bob.locator('.primary-nav a[href="/spaces"]').click() await expect(spaceRow).toBeVisible() await expect(unreadDot(spaceRow)).toHaveCount(0) await expect(unreadDot(navItem)).toHaveCount(0) }) test("US-104 mute a room or a whole space", async ({seed, as}) => { const scenario = await seed(({relay, user}) => { const space = relay("space") space.room("general", {name: "General"}) space.room("random", {name: "Random"}) space.join(user.alice, "general", "random") space.join(user.bob, "general", "random") seedRelays(space, user.alice) }) const space = scenario.space("space") // Silencing a whole space is only offered once push notifications are on, so that is where alice // has to start. const alice = await as(users.alice, "/settings/alerts", { context: {permissions: ["notifications"]}, }) await settingRow(alice, "Enable push notifications").getByRole("checkbox").check() await alice.getByRole("button", {name: "Save Changes"}).click() await expect(alice.getByRole("alert")).toContainText("Your settings have been saved!") await spaceNavItem(alice, space.name).click() const general = roomLink(alice, "General") const random = roomLink(alice, "Random") await general.click() await expect(alice).toHaveURL(pattern(roomPath(space.url, "general"))) // Silence this one room from its detail panel await openRoomDetail(alice) const roomNotifications = settingRow(alice, "Notifications").getByRole("checkbox") await expect(roomNotifications).toBeChecked() await roomNotifications.uncheck() await alice.getByRole("button", {name: "Go back"}).click() await expect(random).toBeVisible() await expect(mutedRoomBell(general)).toBeVisible() await expect(mutedRoomBell(random)).toHaveCount(0) // Step out of both rooms, so either one is free to raise a dot await alice.getByRole("link", {name: "Space Details"}).click() const bob = await as(users.bob, roomPath(space.url, "general")) await send(bob, "deploy is broken") await postTo(bob, "Random", "lunch?") // The sibling room's message raises a dot, the muted room's does not await expect(unreadDot(random)).toBeVisible() await expect(unreadDot(general)).toHaveCount(0) // The muted room's message did arrive, it just raised nothing await general.click() await expect(message(alice, "deploy is broken")).toBeVisible() // Turning the room back on restores its indicator for what comes next await openRoomDetail(alice) await roomNotifications.check() await alice.getByRole("button", {name: "Go back"}).click() await alice.getByRole("link", {name: "Space Details"}).click() await expect(general).toBeVisible() await expect(mutedRoomBell(general)).toHaveCount(0) await postTo(bob, "General", "and now the build too") // Both rooms are now showing a dot, so silencing the space is what takes them down await expect(unreadDot(general)).toBeVisible() await expect(unreadDot(random)).toBeVisible() const menu = spaceMenu(alice, space.url) await menu.click() await alice.getByRole("button", {name: "Turn off notifications"}).click() await expect(mutedSpaceBell(menu)).toBeVisible() await expect(unreadDot(general)).toHaveCount(0) await expect(unreadDot(random)).toHaveCount(0) await postTo(bob, "General", "still broken") await postTo(bob, "Random", "still hungry") await expect(unreadDot(general)).toHaveCount(0) await expect(unreadDot(random)).toHaveCount(0) // Reopening the menu shows the label the mute flipped, and turning it back on brings the // indicators back — which is also what proves those messages reached alice at all await menu.click() const turnOn = alice.getByRole("button", {name: "Turn on notifications"}) await expect(turnOn).toBeVisible() await turnOn.click() await expect(mutedSpaceBell(menu)).toHaveCount(0) await expect(unreadDot(general)).toBeVisible() await expect(unreadDot(random)).toBeVisible() }) test("US-105 land on the home page", async ({seed, as}) => { const scenario = await seed(({relay, user}) => { const space = relay("space") space.room("general", {name: "General"}) space.join(user.alice, "general") }) const space = scenario.space("space") // A build that names a platform space sends /home straight into it const platform = await as(users.alice, "/home", {env: {VITE_PLATFORM_RELAYS: space.url}}) await expect(platform).toHaveURL(pattern(spacePath(space.url))) // With none configured, /home is a welcome screen with two ways out const page = await as(users.alice, "/home") const addSpace = page.getByRole("link", {name: "Add a space"}) const startConversation = page.getByRole("button", {name: "Start a conversation"}) await expect(addSpace).toBeVisible() await expect(startConversation).toBeVisible() await addSpace.click() await expect(page).toHaveURL(/\/spaces$/) await page.goBack() await expect(page).toHaveURL(/\/home$/) await startConversation.click() await expect(page).toHaveURL(/\/chat$/) }) test("US-106 share text into the app", async ({seed, as}) => { const shared = "the offsite is moving to the 14th" const scenario = await seed(({relay, user, at}) => { const space = relay("space") space.room("general", {name: "General"}) space.room("watercooler", {name: "Watercooler"}) space.join(user.alice, "general", "watercooler") space.join(user.bob, "general", "watercooler") space.profile(user.bob, {name: "Bob Barker"}) seedRelays(space, user.alice) seedRelays(space, user.bob) space.dm(user.bob, [user.alice], "are you around?", at(20, MINUTE)) }) const space = scenario.space("space") // A client only unwraps direct messages once its owner has opened chat, so a conversation the // share dialog can offer is one alice has already seen. const page = await as(users.alice, "/chat") await expect(page.getByText("are you around?").first()).toBeVisible() // The text arrives in the query string rather than through a native share intent, which is the // same thing src/routes/share reads either way. await page.goto(`/share?text=${encodeURIComponent(shared)}`) const share = dialog(page, "Share") await expect(page.getByText("Where would you like to share this?")).toBeVisible() await expect(share.getByRole("button", {name: "General"})).toBeVisible() await expect(share.getByRole("button", {name: "Watercooler"})).toBeVisible() await expect(share.getByRole("button", {name: "Bob Barker"})).toBeVisible() const search = page.getByPlaceholder("Search rooms and conversations...") await search.fill("Watercooler") await expect(share.getByRole("button", {name: "Watercooler"})).toBeVisible() await expect(share.getByRole("button", {name: "General"})).toHaveCount(0) await expect(share.getByRole("button", {name: "Bob Barker"})).toHaveCount(0) await search.fill("") await share.getByRole("button", {name: "General"}).click() await share.getByRole("button", {name: "Share", exact: true}).click() await expect(page).toHaveURL(pattern(roomPath(space.url, "general"))) await expect(composer(page)).toHaveText(shared) }) test("US-107 open a nostr link", async ({seed, as}) => { let posted!: Seeded const scenario = await seed(({relay, user, at}) => { const space = relay("space") space.room("general", {name: "General"}) space.join(user.alice, "general") space.join(user.bob, "general") space.profile(user.bob, {name: "Bob Barker"}) seedRelays(space, user.bob) posted = space.message(user.bob, "general", "the meeting moved to friday", at(20, MINUTE)) }) const space = scenario.space("space") const page = await as(users.alice, roomPath(space.url, "general"), { context: {permissions: ["clipboard-read", "clipboard-write"]}, }) const item = page.locator(`[data-event="${posted.id}"]`) await expect(item).toBeVisible() await item.hover() await item.locator(".room__item-actions button").last().click() await page.getByRole("button", {name: "Message Details"}).click() await expect(page.getByRole("heading", {name: "Event Details"})).toBeVisible() // EventInfo renders the link on mount rather than during setup, so wait for it rather than // copying an empty field const eventLink = settingRow(page, "Event Link") await expect(eventLink.getByRole("textbox")).toHaveValue(/^nostr:nevent1/) await eventLink.getByRole("button").click() await expect(page.getByRole("alert")).toContainText("Copied to clipboard!") const link = await page.evaluate(() => navigator.clipboard.readText()) expect(link).toMatch(/^nostr:nevent1/) await page.goto("/" + link.replace(/^nostr:/, "")) await expect(page).toHaveURL(pattern(roomPath(space.url, "general") + "?at=")) await expect(message(page, "the meeting moved to friday")).toBeVisible() // A person's npub resolves the same way const npub = npubEncode(users.bob.pubkey) await page.goto(`/${npub}`) await expect(page).toHaveURL(pattern(`/people/${npub}`)) await expect(page.getByRole("heading", {name: "Bob Barker"})).toBeVisible() // Nothing holds this event, so the link falls back to the app's home rather than a dead page await page.goto(`/${neventEncode({id: "f".repeat(64), relays: [space.url]})}`) await expect(page).toHaveURL(/\/home$/) }) test("US-110 see another space's unread activity from a phone", async ({seed, as}) => { const scenario = await seed(({relay, user}) => { const space = relay("space") const other = relay("other") space.room("general", {name: "General"}) space.room("random", {name: "Random"}) other.room("general", {name: "General"}) space.join(user.alice, "general", "random") space.join(user.bob, "general", "random") other.join(user.alice, "general") other.join(user.bob, "general") }) const space = scenario.space("space") const other = scenario.space("other") // A phone has no room list on screen while a room is open, so the bottom bar is the only place // activity elsewhere can surface const bob = await as(users.bob, roomPath(space.url, "general"), { context: {viewport: {width: 390, height: 844}, hasTouch: true}, }) const spacesButton = spacesNavItem(bob) await expect(spacesButton).toBeVisible() await expect(unreadDot(spacesButton)).toHaveCount(0) const inOther = await as(users.alice, roomPath(other.url, "general")) await send(inOther, "the server is on fire") await expect(unreadDot(spacesButton)).toBeVisible() // Meanwhile the space bob is sitting in gets a message too, in a room he isn't reading const inSpace = await as(users.alice, roomPath(space.url, "random")) await send(inSpace, "anyone seen the sextant?") await bob.goto(roomPath(other.url, "general")) await expect(message(bob, "the server is on fire")).toBeVisible() // Reading the other space empties the bar, even though bob's own space still has an unread room // — that one is the space's business, and its own indicators carry it await bob.goto(spacePath(space.url)) await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible() await expect(unreadDot(spacesButton)).toHaveCount(0) })