diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md
index 26c1bcd6..61b29d62 100644
--- a/e2e/USER_STORIES.md
+++ b/e2e/USER_STORIES.md
@@ -455,6 +455,8 @@ Acceptance:
- The conversation appears at the top of her list, labeled with bob's name.
- Bob's profile page offers a "Message" button that opens the same conversation;
her own profile offers none.
+- A profile modal has no such button, so its "..." menu offers "Send Message"
+ instead; the profile page's menu does not repeat it.
### US-030 — Start a group chat
diff --git a/e2e/specs/dms.spec.ts b/e2e/specs/dms.spec.ts
index e476091e..2ce8abbc 100644
--- a/e2e/specs/dms.spec.ts
+++ b/e2e/specs/dms.spec.ts
@@ -60,6 +60,14 @@ const unreadDots = (scope: Locator) => scope.locator(".rounded-full.bg-primary")
const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
+// A modal is a `.dialog` overlay wrapping a `.dialog` card, and the card is the one with the
+// content in it.
+const dialog = (page: Page) => page.locator(".dialog").last()
+
+// The "..." menu on a profile header, which is 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")
+
// A modal's body is the only scroll container carrying its title.
const modalBody = (page: Page, title: string) =>
page
@@ -68,8 +76,13 @@ const modalBody = (page: Page, title: string) =>
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"]')
+// The editor is where the composer says whether it is ready. The send button is not there to
+// ask while the composer is empty, since a dictation button stands in its place.
+const composerEnabled = (page: Page) =>
+ expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "false")
+
+const composerDisabled = (page: Page) =>
+ expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "true")
// Both things the composer puts above itself — the message being replied to and the editing
// indicator — are the same bordered strip.
@@ -84,7 +97,7 @@ const enablePrompt = (page: Page) => page.getByRole("heading", {name: "Enable di
// 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 composerEnabled(page)
await composer(page).pressSequentially(content)
await composer(page).press("Enter")
}
@@ -183,7 +196,7 @@ test("US-029 start a one-on-one chat", async ({seed, as}) => {
await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
await expect(composer(page)).toBeVisible()
- await expect(sendButton(page)).toBeEnabled()
+ await composerEnabled(page)
await send(page, "hi bob")
@@ -204,6 +217,23 @@ test("US-029 start a one-on-one chat", async ({seed, as}) => {
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(dialog(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")
+
// Her own profile offers no way to message herself
await page.goto(profilePath(users.alice.pubkey))
@@ -241,7 +271,7 @@ test("US-030 start a group chat", async ({seed, as}) => {
// 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()
+ await composerEnabled(page)
// Labeled with both of them rather than with one
await expect(pageBar(page)).toContainText("Bob Barnacle")
@@ -294,7 +324,7 @@ test("US-031 direct messaging has to be switched on", async ({seed, as}) => {
const banner = alice.locator(".card").filter({hasText: "Direct messages are not enabled"})
await expect(banner).toContainText("Bob Barnacle")
- await expect(sendButton(alice)).toBeDisabled()
+ await composerDisabled(alice)
const bob = await as(users.bob, "/")
@@ -319,7 +349,7 @@ test("US-031 direct messaging has to be switched on", async ({seed, as}) => {
// 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 composerEnabled(alice)
await expect(banner).toHaveCount(0)
await send(alice, "finally")
diff --git a/src/app/components/ProfileDetail.svelte b/src/app/components/ProfileDetail.svelte
index 05c40a87..3c3b6f68 100644
--- a/src/app/components/ProfileDetail.svelte
+++ b/src/app/components/ProfileDetail.svelte
@@ -5,6 +5,7 @@
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import UserMinus from "@assets/icons/user-minus.svg?dataurl"
+ import Letter from "@assets/icons/letter-opened.svg?dataurl"
import Restart from "@assets/icons/restart.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Confirm from "@lib/components/Confirm.svelte"
@@ -16,11 +17,11 @@
import ProfileAbout from "@app/components/ProfileAbout.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ProfileMenu from "@app/components/ProfileMenu.svelte"
- import {messagingRelayLists, profiles, relayManagement} from "@app/core"
+ import {messagingRelayLists, profiles, relayManagement, user} from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management"
- import {navigate, pushModal} from "@app/modal"
+ import {navigate, popModal, pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
- import {makeProfilePath} from "@app/routes"
+ import {goToChat, makeProfilePath} from "@app/routes"
export type Props = {
pubkey: string
@@ -31,10 +32,17 @@
const userIsAdmin = deriveUserIsSpaceAdmin(url)
+ const isSelf = $derived($user.pubkey === pubkey)
+
const back = () => history.back()
const viewProfile = () => navigate(makeProfilePath(pubkey), {replaceState: true})
+ const sendMessage = () => {
+ popModal()
+ goToChat([pubkey])
+ }
+
const report = (error: string | undefined, message: string) => {
if (error) {
pushToast({theme: "error", message: error})
@@ -92,24 +100,32 @@
{#snippet customActions()}
+ {#if !isSelf}
+