diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 88ec8080aab..f650178e950 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -875,7 +875,8 @@ const MessageTimelineBase = React.forwardRef<
)}
- {!isAtBottom ? (
+ {/* Physical bottom may be the frozen tail, not the newest message. */}
+ {!isAtBottom || bufferedTimeline.pendingCount > 0 ? (
window.getSelection()?.toString()),
+ ).toBe("@Scout hello");
+ await page.keyboard.insertText("@Scout");
+ await page.getByTestId(`mention-suggestion-${FIRST}`).click();
+ await page.keyboard.type("hello");
+ await expect(input).toHaveText("@Scout hello");
+ await page.getByTestId("send-message").click();
+ await expect
+ .poll(() => recipients(page, "@Scout hello"))
+ .toEqual([[FIRST]]);
+ await expect(input).toHaveText("");
+ }
});
}
diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts
index 67641d1c38e..66f3f6ccc12 100644
--- a/desktop/tests/e2e/messaging.spec.ts
+++ b/desktop/tests/e2e/messaging.spec.ts
@@ -12,6 +12,25 @@ const LINK_PREVIEW_IMAGE = readFileSync(
);
const LINK_PREVIEW_IMAGE_DATA_URL = `data:image/png;base64,${LINK_PREVIEW_IMAGE.toString("base64")}`;
+async function replaceThreadEdit(
+ input: Locator,
+ source: string,
+ replacement: string,
+) {
+ // The Edit action must seed this scoped composer and deliver its initial focus.
+ // Focus is not an idle fence: later PM focus callbacks project the current
+ // selection. Mod+A commits AllSelection through Tiptap, unlike DOM-only fill.
+ await expect(input).toHaveText(source);
+ await expect(input).toBeFocused();
+ await input.press("ControlOrMeta+a");
+ expect(await input.evaluate(() => window.getSelection()?.toString())).toBe(
+ source,
+ );
+ await input.page().keyboard.insertText(replacement);
+ // Observe the first replacement before any refused-navigation action.
+ expect(await input.textContent()).toBe(replacement);
+}
+
async function waitForReadyComposerSnapshots(
page: import("@playwright/test").Page,
count = 1,
@@ -1707,13 +1726,12 @@ test("send multiple messages in sequence", async ({ page }) => {
test("copy a rendered code block and paste it back as code", async ({
page,
}) => {
- await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
- origin: "http://127.0.0.1:4173",
- });
-
const code = "# not a heading\nconst answer = 42;\n indented();";
await page.goto("/");
+ await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
+ origin: new URL(page.url()).origin,
+ });
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -1750,16 +1768,15 @@ test("copy a rendered code block and paste it back as code", async ({
test("pasting a long copied code block scrolls composer to cursor", async ({
page,
}) => {
- await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
- origin: "http://127.0.0.1:4173",
- });
-
const longCode = Array.from(
{ length: 48 },
(_, index) => `const line${index} = ${index};`,
).join("\n");
await page.goto("/");
+ await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
+ origin: new URL(page.url()).origin,
+ });
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -1794,11 +1811,10 @@ test("pasting a long copied code block scrolls composer to cursor", async ({
test("code block shows language label when language is specified", async ({
page,
}) => {
+ await page.goto("/");
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
- origin: "http://127.0.0.1:4173",
+ origin: new URL(page.url()).origin,
});
-
- await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -2434,285 +2450,354 @@ test("send message to DM channel p-tags the recipient", async ({ page }) => {
.toContainEqual(["p", TEST_IDENTITIES.alice.pubkey]);
});
-test("sends a thread message to its parent channel with a root-thread link", async ({
- page,
-}) => {
- const timestamp = Date.now();
- const rootContent = `🧵 Share source thread ${timestamp}`;
- const priorChannelMessage = `Prior channel message ${timestamp}`;
- const replySummary = `Share this reply ${timestamp}`;
- const attachmentSha = "d".repeat(64);
- const attachmentUrl = `http://localhost:3000/media/${attachmentSha}.txt`;
- const customEmojiUrl = "https://example.com/send-to-channel-party.svg";
- const previewUrl = "https://github.com/block/buzz/pull/5305";
- const ownReplyContent = [
- `${replySummary} with @alice :party:`,
- `[launch-notes.txt](${attachmentUrl})`,
- previewUrl,
- ].join("\n\n");
- const imetaTag = [
- "imeta",
- `url ${attachmentUrl}`,
- "m text/plain",
- `x ${attachmentSha}`,
- "size 42",
- "filename launch-notes.txt",
- ];
- const emojiTag = ["emoji", "party", customEmojiUrl];
- const mentionTag = ["mention", TEST_IDENTITIES.alice.pubkey];
- const linkPreviewTag = [
- "link-preview",
- "snapshot",
- "1",
- previewUrl,
- "Add Send to channel for thread messages",
- "GitHub",
- "A shared link preview preserved from the source thread message.",
- "",
- "",
- "",
- "",
- ];
+for (const recovery of [false, true]) {
+ test(
+ recovery
+ ? "wheel return exposes stranded shared reply recovery before panel close"
+ : "sends a thread message to its parent channel with a root-thread link",
+ async ({ page }) => {
+ const timestamp = Date.now();
+ const rootContent = `🧵 Share source thread ${timestamp}`;
+ const priorChannelMessage = `Prior channel message ${timestamp}`;
+ const replySummary = `Share this reply ${timestamp}`;
+ const attachmentSha = "d".repeat(64);
+ const attachmentUrl = `http://localhost:3000/media/${attachmentSha}.txt`;
+ const customEmojiUrl = "https://example.com/send-to-channel-party.svg";
+ const previewUrl = "https://github.com/block/buzz/pull/5305";
+ const ownReplyContent = [
+ `${replySummary} with @alice :party:`,
+ `[launch-notes.txt](${attachmentUrl})`,
+ previewUrl,
+ ].join("\n\n");
+ const imetaTag = [
+ "imeta",
+ `url ${attachmentUrl}`,
+ "m text/plain",
+ `x ${attachmentSha}`,
+ "size 42",
+ "filename launch-notes.txt",
+ ];
+ const emojiTag = ["emoji", "party", customEmojiUrl];
+ const mentionTag = ["mention", TEST_IDENTITIES.alice.pubkey];
+ const linkPreviewTag = [
+ "link-preview",
+ "snapshot",
+ "1",
+ previewUrl,
+ "Add Send to channel for thread messages",
+ "GitHub",
+ "A shared link preview preserved from the source thread message.",
+ "",
+ "",
+ "",
+ "",
+ ];
+
+ await page.route(customEmojiUrl, (route) =>
+ route.fulfill({
+ body: '
',
+ contentType: "image/svg+xml",
+ }),
+ );
- await page.route(customEmojiUrl, (route) =>
- route.fulfill({
- body: '
',
- contentType: "image/svg+xml",
- }),
- );
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await page.waitForFunction(
+ () =>
+ typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
+ (window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
+ channelName: "general",
+ }) ??
+ false),
+ );
- await page.goto("/");
- await page.getByTestId("channel-general").click();
- await expect(page.getByTestId("chat-title")).toHaveText("general");
- await page.waitForFunction(
- () =>
- typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
- (window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
- channelName: "general",
- }) ??
- false),
- );
+ const { ownReplyId, rootId } = await page.evaluate(
+ ({ alicePubkey, ownReply, root, semanticTags }) => {
+ const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
+ if (!emit) throw new Error("Mock message emitter is unavailable.");
+ const rootEvent = emit({
+ channelName: "general",
+ content: root,
+ pubkey: alicePubkey,
+ });
+ const ownReplyEvent = emit({
+ channelName: "general",
+ content: ownReply,
+ extraTags: semanticTags,
+ mentionPubkeys: [alicePubkey],
+ parentEventId: rootEvent.id,
+ });
+ return {
+ ownReplyId: ownReplyEvent.id,
+ rootId: rootEvent.id,
+ };
+ },
+ {
+ alicePubkey: TEST_IDENTITIES.alice.pubkey,
+ ownReply: ownReplyContent,
+ root: rootContent,
+ semanticTags: [imetaTag, emojiTag, mentionTag, linkPreviewTag],
+ },
+ );
- const { ownReplyId, rootId } = await page.evaluate(
- ({ alicePubkey, ownReply, root, semanticTags }) => {
- const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
- if (!emit) throw new Error("Mock message emitter is unavailable.");
- const rootEvent = emit({
- channelName: "general",
- content: root,
- pubkey: alicePubkey,
- });
- const ownReplyEvent = emit({
- channelName: "general",
- content: ownReply,
- extraTags: semanticTags,
- mentionPubkeys: [alicePubkey],
- parentEventId: rootEvent.id,
+ const timeline = page.getByTestId("message-timeline");
+ const rootRow = timeline.locator(`[data-message-id="${rootId}"]`);
+ await expect(rootRow).toContainText(rootContent);
+
+ await page.getByTestId("message-input").fill(priorChannelMessage);
+ await page.getByTestId("send-message").click();
+ const priorChannelRow = timeline
+ .getByTestId("message-row")
+ .filter({ hasText: priorChannelMessage });
+ await expect(priorChannelRow).toBeVisible();
+ await expect(
+ priorChannelRow.getByTestId("message-send-status"),
+ ).toHaveCount(0);
+
+ await timeline
+ .locator(
+ `[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
+ )
+ .click();
+ const threadPanel = page.getByTestId("message-thread-panel");
+ const threadRootRow = threadPanel.locator(
+ `[data-message-id="${rootId}"]`,
+ );
+ const rootMoreActions = threadRootRow.getByTestId(
+ `more-actions-${rootId}`,
+ );
+ await threadRootRow.hover();
+ await rootMoreActions.click();
+ await expect(page.getByRole("menu")).toBeVisible();
+ await expect(
+ page.getByRole("menuitem", { name: "Send to channel" }),
+ ).toHaveCount(0);
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("menu")).toHaveCount(0);
+
+ let scrollBox: { x: number; y: number } | null = null;
+ if (recovery) {
+ // Read history before sharing, then return with native input rather than
+ // relying on panel-close reflow to release the semantic tail buffer.
+ await page.setViewportSize({ width: 1000, height: 500 });
+ scrollBox = await timeline.boundingBox();
+ if (!scrollBox) throw new Error("Missing timeline geometry");
+ await page.mouse.move(scrollBox.x + 40, scrollBox.y + 60);
+ await page.mouse.wheel(0, -600);
+ await expect(
+ page.getByTestId("message-scroll-to-latest"),
+ ).toBeVisible();
+ }
+ if (!recovery) {
+ await expect
+ .poll(() =>
+ timeline.evaluate(
+ (element) =>
+ element.scrollHeight - element.clientHeight - element.scrollTop,
+ ),
+ )
+ .toBeLessThanOrEqual(32);
+ }
+ const ownReplyRow = threadPanel.locator(
+ `[data-message-id="${ownReplyId}"]`,
+ );
+ await expect(ownReplyRow).toContainText(replySummary);
+ await ownReplyRow
+ .getByTestId(`more-actions-${ownReplyId}`)
+ .press("Enter");
+ const sendToChannelItem = page.getByRole("menuitem", {
+ name: "Send to channel",
});
- return {
- ownReplyId: ownReplyEvent.id,
- rootId: rootEvent.id,
- };
- },
- {
- alicePubkey: TEST_IDENTITIES.alice.pubkey,
- ownReply: ownReplyContent,
- root: rootContent,
- semanticTags: [imetaTag, emojiTag, mentionTag, linkPreviewTag],
- },
- );
-
- const timeline = page.getByTestId("message-timeline");
- const rootRow = timeline.locator(`[data-message-id="${rootId}"]`);
- await expect(rootRow).toContainText(rootContent);
-
- await page.getByTestId("message-input").fill(priorChannelMessage);
- await page.getByTestId("send-message").click();
- const priorChannelRow = timeline
- .getByTestId("message-row")
- .filter({ hasText: priorChannelMessage });
- await expect(priorChannelRow).toBeVisible();
- await expect(priorChannelRow.getByTestId("message-send-status")).toHaveCount(
- 0,
- );
-
- await timeline
- .locator(
- `[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
- )
- .click();
- const threadPanel = page.getByTestId("message-thread-panel");
- const threadRootRow = threadPanel.locator(`[data-message-id="${rootId}"]`);
- const rootMoreActions = threadRootRow.getByTestId(`more-actions-${rootId}`);
- await rootMoreActions.click({ force: true });
- await expect(page.getByRole("menu")).toBeVisible();
- await expect(
- page.getByRole("menuitem", { name: "Send to channel" }),
- ).toHaveCount(0);
- await page.keyboard.press("Escape");
- await expect(page.getByRole("menu")).toHaveCount(0);
-
- const ownReplyRow = threadPanel.locator(`[data-message-id="${ownReplyId}"]`);
- await expect(ownReplyRow).toContainText(replySummary);
- await ownReplyRow
- .getByTestId(`more-actions-${ownReplyId}`)
- .click({ force: true });
- const sendToChannelItem = page.getByRole("menuitem", {
- name: "Send to channel",
- });
- const sendToChannelIcon = sendToChannelItem.getByTestId(
- "send-to-channel-icon",
- );
- await expect(sendToChannelIcon).toBeVisible();
- await expect(sendToChannelIcon).toHaveAttribute("aria-hidden", "true");
- await expect(sendToChannelIcon).toHaveClass(/lucide-hash-arrow-in/);
- await expect
- .poll(async () => {
- const box = await sendToChannelIcon.boundingBox();
- return box ? [box.width, box.height] : null;
- })
- .toEqual([16, 16]);
- await sendToChannelItem.click();
-
- await expect(
- page.locator("[data-sonner-toast]").filter({ hasText: "Sent to channel" }),
- ).toBeVisible();
- await expect
- .poll(() =>
- page.evaluate((content) => {
- return Boolean(
+ const sendToChannelIcon = sendToChannelItem.getByTestId(
+ "send-to-channel-icon",
+ );
+ await expect(sendToChannelIcon).toBeVisible();
+ await expect(sendToChannelIcon).toHaveAttribute("aria-hidden", "true");
+ await expect(sendToChannelIcon).toHaveClass(/lucide-hash-arrow-in/);
+ await expect
+ .poll(async () => {
+ const box = await sendToChannelIcon.boundingBox();
+ return box ? [box.width, box.height] : null;
+ })
+ .toEqual([16, 16]);
+ await sendToChannelItem.click();
+
+ await expect(
+ page
+ .locator("[data-sonner-toast]")
+ .filter({ hasText: "Sent to channel" }),
+ ).toBeVisible();
+ await expect
+ .poll(() =>
+ page.evaluate((content) => {
+ return Boolean(
+ (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast(
+ (entry) =>
+ entry.command === "send_channel_message" &&
+ (entry.payload as { content?: string } | undefined)
+ ?.content === content,
+ ),
+ );
+ }, ownReplyContent),
+ )
+ .toBe(true);
+ const sentPayload = await page.evaluate(
+ (content) =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast(
(entry) =>
entry.command === "send_channel_message" &&
(entry.payload as { content?: string } | undefined)?.content ===
content,
- ),
- );
- }, ownReplyContent),
- )
- .toBe(true);
- const sentPayload = await page.evaluate(
- (content) =>
- (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast(
- (entry) =>
- entry.command === "send_channel_message" &&
- (entry.payload as { content?: string } | undefined)?.content ===
- content,
- )?.payload as Record
| undefined,
- ownReplyContent,
- );
- expect(sentPayload).toMatchObject({
- channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
- content: ownReplyContent,
- emojiTags: [emojiTag],
- linkPreviewTags: [linkPreviewTag],
- mediaTags: [imetaTag],
- mentionPubkeys: [TEST_IDENTITIES.alice.pubkey],
- mentionTags: [mentionTag],
- parentEventId: null,
- sentFromThreadTag: ["buzz:sent-from-thread", rootId, rootContent],
- });
-
- await page.getByTestId("auxiliary-panel-close").click();
-
- const sharedRow = timeline
- .getByTestId("message-row")
- .filter({ hasText: replySummary })
- .last();
- await expect
- .poll(async () => {
- if (await sharedRow.isVisible()) return true;
- const scrollToLatest = page.getByTestId("message-scroll-to-latest");
- if (await scrollToLatest.isVisible()) await scrollToLatest.click();
- return false;
- })
- .toBe(true);
- await expect(sharedRow.getByTestId("message-author")).toHaveText(
- "npub1mock...",
- );
- await expect(sharedRow.getByTestId("message-avatar-fallback")).toBeVisible();
- await expect(sharedRow.locator('[data-mention=""]')).toContainText("alice");
- await expect(sharedRow.locator("img[data-custom-emoji]")).toHaveAttribute(
- "src",
- customEmojiUrl,
- );
- await expect(sharedRow.getByTestId("file-card")).toContainText(
- "launch-notes.txt",
- );
- await expect(
- sharedRow.locator('[data-link-preview="github-pull-request"]'),
- ).toContainText("Add Send to channel for thread messages");
- const sourceLine = sharedRow.getByTestId("sent-from-thread");
- await expect(sourceLine).toContainText("Sent from thread:");
- await expect(sourceLine).toHaveClass(/message-markdown/);
- await expect(sourceLine).toHaveClass(/pt-0\.5/);
- await expect(sourceLine).toHaveClass(/text-sm/);
- await expect(sourceLine).toHaveClass(/font-normal/);
- await expect(sourceLine).toHaveClass(/leading-4/);
- await expect(sourceLine).toHaveClass(/text-muted-foreground\/70/);
- const rootLink = sourceLine.locator("[data-message-link]");
- const sourcePrefix = sourceLine.locator("span").first();
- const rootLinkLabel = rootContent;
- await expect(rootLink).toHaveText(rootLinkLabel);
- await expect(rootLink).toHaveAttribute(
- "aria-label",
- "Open thread in general",
- );
- await expect(rootLink).toHaveAttribute("title", rootLinkLabel);
- await expect(rootLink).toHaveClass(/max-w-80/);
- await expect(rootLink).toHaveClass(/truncate/);
- await expect(rootLink).toHaveClass(/inline-block/);
- await expect(rootLink).toHaveClass(/font-medium/);
- await expect(rootLink).not.toHaveClass(/mention-chip/);
- await expect(rootLink).not.toHaveClass(/border-b/);
- const rootLinkText = rootLink.locator("[data-message-link-text]");
- const rootLinkEmoji = rootLink.locator("[data-message-link-emoji]");
- await expect(rootLinkText).toHaveText(` Share source thread ${timestamp}`);
- await expect(rootLinkText).not.toHaveClass(/border-b/);
- await expect(rootLinkEmoji).toHaveText("🧵");
- await expect(rootLinkEmoji).not.toHaveClass(/border-b/);
- await expect(rootLink).not.toHaveAttribute("data-hovered");
- const [prefixColor, linkColorBeforeHover] = await Promise.all([
- sourcePrefix.evaluate((element) => getComputedStyle(element).color),
- rootLink.evaluate((element) => getComputedStyle(element).color),
- ]);
- expect(linkColorBeforeHover).not.toBe(prefixColor);
- await expect
- .poll(() =>
- rootLink.evaluate((element) => getComputedStyle(element).backgroundColor),
- )
- .toBe("rgba(0, 0, 0, 0)");
- await expect
- .poll(() =>
- rootLinkText.evaluate((element) => getComputedStyle(element).boxShadow),
- )
- .toBe("none");
-
- await rootLink.hover();
- await expect(rootLink).toHaveAttribute("data-hovered", "");
- await expect
- .poll(() => rootLink.evaluate((element) => getComputedStyle(element).color))
- .toBe(linkColorBeforeHover);
- await expect
- .poll(() =>
- rootLinkText.evaluate(
- (element) => getComputedStyle(element).boxShadow !== "none",
- ),
- )
- .toBe(true);
+ )?.payload as Record | undefined,
+ ownReplyContent,
+ );
+ expect(sentPayload).toMatchObject({
+ channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
+ content: ownReplyContent,
+ emojiTags: [emojiTag],
+ linkPreviewTags: [linkPreviewTag],
+ mediaTags: [imetaTag],
+ mentionPubkeys: [TEST_IDENTITIES.alice.pubkey],
+ mentionTags: [mentionTag],
+ parentEventId: null,
+ sentFromThreadTag: ["buzz:sent-from-thread", rootId, rootContent],
+ });
- await expect
- .poll(() =>
- rootLinkEmoji.evaluate((element) => getComputedStyle(element).boxShadow),
- )
- .toBe("none");
+ if (recovery) {
+ if (!scrollBox) throw new Error("Missing recovery timeline geometry");
+ const latest = page.getByTestId("message-scroll-to-latest");
+ await expect(latest).toBeVisible();
+ await page.mouse.move(scrollBox.x + 40, scrollBox.y + 60);
+ await page.mouse.wheel(0, 600);
+ // A real return to the frozen model's floor must not hide pending work.
+ await expect
+ .poll(() =>
+ timeline.evaluate(
+ (element) =>
+ element.scrollHeight - element.clientHeight - element.scrollTop,
+ ),
+ )
+ .toBe(0);
+ await expect(latest).toBeVisible();
+ await latest.click();
+ }
+ await page.getByTestId("auxiliary-panel-close").click();
+
+ const sharedRow = timeline
+ .getByTestId("message-row")
+ .filter({ hasText: replySummary })
+ .last();
+ await expect
+ .poll(async () => {
+ if (await sharedRow.isVisible()) return true;
+ const scrollToLatest = page.getByTestId("message-scroll-to-latest");
+ if (await scrollToLatest.isVisible()) await scrollToLatest.click();
+ return false;
+ })
+ .toBe(true);
+ await expect(sharedRow.getByTestId("message-author")).toHaveText(
+ "npub1mock...",
+ );
+ await expect(
+ sharedRow.getByTestId("message-avatar-fallback"),
+ ).toBeVisible();
+ await expect(sharedRow.locator('[data-mention=""]')).toContainText(
+ "alice",
+ );
+ await expect(sharedRow.locator("img[data-custom-emoji]")).toHaveAttribute(
+ "src",
+ customEmojiUrl,
+ );
+ await expect(sharedRow.getByTestId("file-card")).toContainText(
+ "launch-notes.txt",
+ );
+ await expect(
+ sharedRow.locator('[data-link-preview="github-pull-request"]'),
+ ).toContainText("Add Send to channel for thread messages");
+ const sourceLine = sharedRow.getByTestId("sent-from-thread");
+ await expect(sourceLine).toContainText("Sent from thread:");
+ await expect(sourceLine).toHaveClass(/message-markdown/);
+ await expect(sourceLine).toHaveClass(/pt-0\.5/);
+ await expect(sourceLine).toHaveClass(/text-sm/);
+ await expect(sourceLine).toHaveClass(/font-normal/);
+ await expect(sourceLine).toHaveClass(/leading-4/);
+ await expect(sourceLine).toHaveClass(/text-muted-foreground\/70/);
+ const rootLink = sourceLine.locator("[data-message-link]");
+ const sourcePrefix = sourceLine.locator("span").first();
+ const rootLinkLabel = rootContent;
+ await expect(rootLink).toHaveText(rootLinkLabel);
+ await expect(rootLink).toHaveAttribute(
+ "aria-label",
+ "Open thread in general",
+ );
+ await expect(rootLink).toHaveAttribute("title", rootLinkLabel);
+ await expect(rootLink).toHaveClass(/max-w-80/);
+ await expect(rootLink).toHaveClass(/truncate/);
+ await expect(rootLink).toHaveClass(/inline-block/);
+ await expect(rootLink).toHaveClass(/font-medium/);
+ await expect(rootLink).not.toHaveClass(/mention-chip/);
+ await expect(rootLink).not.toHaveClass(/border-b/);
+ const rootLinkText = rootLink.locator("[data-message-link-text]");
+ const rootLinkEmoji = rootLink.locator("[data-message-link-emoji]");
+ await expect(rootLinkText).toHaveText(
+ ` Share source thread ${timestamp}`,
+ );
+ await expect(rootLinkText).not.toHaveClass(/border-b/);
+ await expect(rootLinkEmoji).toHaveText("🧵");
+ await expect(rootLinkEmoji).not.toHaveClass(/border-b/);
+ await expect(rootLink).not.toHaveAttribute("data-hovered");
+ const [prefixColor, linkColorBeforeHover] = await Promise.all([
+ sourcePrefix.evaluate((element) => getComputedStyle(element).color),
+ rootLink.evaluate((element) => getComputedStyle(element).color),
+ ]);
+ expect(linkColorBeforeHover).not.toBe(prefixColor);
+ await expect
+ .poll(() =>
+ rootLink.evaluate(
+ (element) => getComputedStyle(element).backgroundColor,
+ ),
+ )
+ .toBe("rgba(0, 0, 0, 0)");
+ await expect
+ .poll(() =>
+ rootLinkText.evaluate(
+ (element) => getComputedStyle(element).boxShadow,
+ ),
+ )
+ .toBe("none");
+
+ await rootLink.hover();
+ await expect(rootLink).toHaveAttribute("data-hovered", "");
+ await expect
+ .poll(() =>
+ rootLink.evaluate((element) => getComputedStyle(element).color),
+ )
+ .toBe(linkColorBeforeHover);
+ await expect
+ .poll(() =>
+ rootLinkText.evaluate(
+ (element) => getComputedStyle(element).boxShadow !== "none",
+ ),
+ )
+ .toBe(true);
- await rootLink.click();
- await expect(threadPanel).toBeVisible();
- await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
- rootContent,
+ await expect
+ .poll(() =>
+ rootLinkEmoji.evaluate(
+ (element) => getComputedStyle(element).boxShadow,
+ ),
+ )
+ .toBe("none");
+
+ await rootLink.click();
+ await expect(threadPanel).toBeVisible();
+ await expect(
+ threadPanel.getByTestId("message-thread-head"),
+ ).toContainText(rootContent);
+ },
);
-});
+}
test("shows your avatar on your own message when profile avatar is set", async ({
page,
@@ -3825,7 +3910,7 @@ test("closing a thread while editing a reply preserves the typed edit", async ({
await threadReply.hover();
await threadReply.getByRole("button", { name: "More actions" }).click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
- await threadInput.fill(edited);
+ await replaceThreadEdit(threadInput, reply, edited);
await threadPanel.getByTestId("auxiliary-panel-close").click();
@@ -4031,7 +4116,7 @@ test("a refused message deep link retries after the thread edit is canceled", as
await threadReply.hover();
await threadReply.getByRole("button", { name: "More actions" }).click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
- await threadInput.fill(`${reply} unsaved`);
+ await replaceThreadEdit(threadInput, reply, `${reply} unsaved`);
const threadUrl = page.url();
expect(threadUrl).toContain(
@@ -4115,7 +4200,7 @@ test("a refused sent-from-thread link preserves the edit and retries after cance
await reply.hover();
await reply.getByRole("button", { name: "More actions" }).click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
- await threadInput.fill(dirtyReply);
+ await replaceThreadEdit(threadInput, sourceReply, dirtyReply);
const threadUrl = page.url();
expect(threadUrl).toContain(`thread=${sourceRootId}`);
@@ -4189,7 +4274,7 @@ test("a refused search result preserves the edit and retries after cancel", asyn
await reply.hover();
await reply.getByRole("button", { name: "More actions" }).click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
- await threadInput.fill(dirtyReply);
+ await replaceThreadEdit(threadInput, sourceReply, dirtyReply);
const threadUrl = page.url();
expect(threadUrl).toContain(`thread=${sourceRootId}`);
@@ -4263,7 +4348,7 @@ test("a refused forum search result preserves the edit and retries after cancel"
await reply.hover();
await reply.getByRole("button", { name: "More actions" }).click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
- await threadInput.fill(dirtyReply);
+ await replaceThreadEdit(threadInput, sourceReply, dirtyReply);
const threadUrl = page.url();
const editTarget = threadPanel.getByTestId("edit-target");
@@ -4346,7 +4431,7 @@ for (const targetKind of ["reply", "root"] as const) {
await reply.hover();
await reply.getByRole("button", { name: "More actions" }).click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
- await threadInput.fill(dirtyReply);
+ await replaceThreadEdit(threadInput, sourceReply, dirtyReply);
const targetLink = timeline
.getByTestId("message-row")
@@ -4440,7 +4525,7 @@ test("a refused channel switch preserves the reply edit and retries after cancel
await reply.hover();
await reply.getByRole("button", { name: "More actions" }).click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
- await threadInput.fill(dirtyReply);
+ await replaceThreadEdit(threadInput, sourceReply, dirtyReply);
const navigationBefore = await page.evaluate(() => ({
historyLength: history.length,
@@ -4524,7 +4609,7 @@ for (const backInput of ["button", "keyboard"] as const) {
await reply.hover();
await reply.getByRole("button", { name: "More actions" }).click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
- await threadInput.fill(dirtyReply);
+ await replaceThreadEdit(threadInput, sourceReply, dirtyReply);
const navigationBefore = await page.evaluate(() => ({
historyLength: history.length,
diff --git a/desktop/tests/e2e/remote-owned-mentions.spec.ts b/desktop/tests/e2e/remote-owned-mentions.spec.ts
index b9d5e9efb8d..15b711958ca 100644
--- a/desktop/tests/e2e/remote-owned-mentions.spec.ts
+++ b/desktop/tests/e2e/remote-owned-mentions.spec.ts
@@ -590,7 +590,19 @@ for (const { incoming, savedFirst } of [
}),
};
}, roots[0]);
- await input.fill("");
+ await input.press("ControlOrMeta+a");
+ expect(
+ await input.evaluate((el) => {
+ const editor = (
+ el as HTMLElement & { editor: import("@tiptap/core").Editor }
+ ).editor;
+ return {
+ selection: editor.state.selection.toJSON(),
+ text: window.getSelection()?.toString(),
+ };
+ }),
+ ).toEqual({ selection: { type: "all" }, text: "new authored text" });
+ await input.press("Backspace");
const deleted = await deletion.evaluate(({ completed }) => completed);
// No assertion/poll between action completion and outgoing-key cleanup.
await navigate(roots[1]);