diff --git a/src/app/NotificationSettings.test.tsx b/src/app/NotificationSettings.test.tsx
new file mode 100644
index 00000000..898b2ea5
--- /dev/null
+++ b/src/app/NotificationSettings.test.tsx
@@ -0,0 +1,147 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { Context } from "@deepseek-ai/cordis";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+} from "@testing-library/react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { PluginRuntime } from "../plugins/runtime";
+import { provideNavigation } from "../features/navigation/service";
+import { createBrowserNotifications } from "../features/notifications/platform";
+import { createNotificationPreferences } from "../features/notifications/preferences";
+import { NotificationsService } from "../features/notifications/service";
+import { NotificationSettings } from "./NotificationSettings";
+
+const contexts: Context[] = [];
+beforeEach(() => {
+ localStorage.clear();
+ vi.useFakeTimers();
+});
+afterEach(async () => {
+ cleanup();
+ for (const ctx of contexts.splice(0)) await ctx.fiber.dispose();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+async function setup(permission: NotificationPermission) {
+ const shown: FakeNotification[] = [];
+ class FakeNotification {
+ static permission = permission;
+ static requestPermission = vi.fn(async () => FakeNotification.permission);
+ onclick: (() => void) | null = null;
+ onclose: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ close = vi.fn();
+ constructor() {
+ shown.push(this);
+ }
+ }
+ vi.stubGlobal("Notification", FakeNotification);
+ const ctx = new Context();
+ contexts.push(ctx);
+ const runtime = new PluginRuntime(ctx, async () => ({ apply() {} }));
+ ctx.effect(() => () => runtime.dispose());
+ const service = new NotificationsService(
+ ctx,
+ provideNavigation(ctx).navigation,
+ createBrowserNotifications(window),
+ createNotificationPreferences(window),
+ );
+ service.selectViewer("a".repeat(64));
+ await service.refreshPermission();
+ render(, {
+ reactStrictMode: true,
+ });
+ return {
+ service,
+ shown,
+ FakeNotification,
+ async submit(sourceKey: string) {
+ let accepted = false;
+ await act(async () => {
+ accepted = await service.admit(
+ "mention",
+ "Mentions",
+ { sourceKey, target: { version: 1, kind: "settings" } },
+ () => true,
+ );
+ });
+ return accepted;
+ },
+ };
+}
+
+// Complete the presentation scheduler before asserting absence of delivery.
+const present = () => act(() => vi.advanceTimersByTimeAsync(100));
+
+it("Allow releases a pending alert and master toggles preserve category choices", async () => {
+ const h = await setup("default");
+ expect(await h.submit("pending")).toBe(true);
+ await present();
+ expect(h.shown).toHaveLength(0);
+ expect(h.FakeNotification.requestPermission).not.toHaveBeenCalled();
+ let grant!: (permission: NotificationPermission) => void;
+ h.FakeNotification.requestPermission.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ grant = resolve;
+ }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Allow notifications" }));
+ try {
+ expect(h.FakeNotification.requestPermission).toHaveBeenCalledOnce();
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Waiting for system permission",
+ );
+ expect(
+ screen.getByRole("button", { name: "Allow notifications" }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: "Check permission" }),
+ ).toBeDisabled();
+ await present();
+ expect(h.shown).toHaveLength(0);
+ } finally {
+ await act(async () => {
+ h.FakeNotification.permission = "granted";
+ grant("granted");
+ });
+ }
+ await present();
+ expect(h.shown).toHaveLength(1);
+ expect(screen.getByRole("status")).toHaveTextContent("Permission granted");
+ fireEvent.click(screen.getByRole("switch", { name: "Mentions" }));
+ fireEvent.click(screen.getByRole("switch", { name: "Desktop alerts" }));
+ fireEvent.click(screen.getByRole("switch", { name: "Desktop alerts" }));
+ expect(screen.getByRole("switch", { name: "Desktop alerts" })).toBeChecked();
+ expect(screen.getByRole("switch", { name: "Mentions" })).not.toBeChecked();
+ expect(await h.submit("muted")).toBe(false);
+ await present();
+ expect(h.shown).toHaveLength(1);
+});
+
+it("browser display errors reach mounted Settings and retire callbacks without redelivery", async () => {
+ const h = await setup("granted");
+ expect(await h.submit("failed")).toBe(true);
+ await present();
+ expect(h.shown).toHaveLength(1);
+ const item = h.shown[0];
+ if (!item) throw new Error("No notification was displayed");
+ act(() => item.onerror?.());
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "The browser could not display a notification.",
+ );
+ expect(item.close).toHaveBeenCalledOnce();
+ expect(item.onclick).toBeNull();
+ expect(item.onerror).toBeNull();
+ expect(item.onclose).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Check permission" }));
+ expect(await h.submit("failed")).toBe(false);
+ await present();
+ expect(h.shown).toHaveLength(1);
+});
diff --git a/src/bundled/channels/UnreadBadge.test.tsx b/src/bundled/channels/UnreadBadge.test.tsx
new file mode 100644
index 00000000..eaa7a4c0
--- /dev/null
+++ b/src/bundled/channels/UnreadBadge.test.tsx
@@ -0,0 +1,71 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { StrictMode } from "react";
+import { afterEach, expect, it, vi } from "vitest";
+import type { RelaySession } from "../../features/relay/session";
+import { UnreadOptions } from "./UnreadBadge";
+
+afterEach(cleanup);
+
+function session(rows: { id: string; membership?: unknown }[]) {
+ const markThrough = vi.fn().mockResolvedValue(undefined);
+ const sync = {
+ capability: "frontier-sync",
+ status: "reconciled",
+ error: null,
+ } as const;
+ const unread = {
+ subscribeSync: () => () => {},
+ sync: () => sync,
+ markUnreadLocal: vi.fn().mockResolvedValue(undefined),
+ markThrough,
+ refresh: vi.fn().mockResolvedValue(undefined),
+ retrySync: vi.fn().mockResolvedValue(undefined),
+ };
+ return {
+ value: {
+ unread,
+ channels: { window: () => ({ rows }) },
+ } as unknown as RelaySession,
+ markThrough,
+ };
+}
+
+it("marks through the newest verified message, never trailing membership activity", async () => {
+ const h = session([
+ { id: "older" },
+ { id: "newest-chat" },
+ { id: "membership", membership: {} },
+ ]);
+ render(
+
+
+ ,
+ );
+ await userEvent.click(
+ screen.getByRole("button", { name: "Mark read through loaded messages" }),
+ );
+ expect(h.markThrough).toHaveBeenCalledExactlyOnceWith(
+ { kind: "channel", channelId: "room" },
+ "newest-chat",
+ );
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+});
+
+it("keeps manual intent when only membership activity is loaded", async () => {
+ const h = session([{ id: "membership", membership: {} }]);
+ render(
+
+
+ ,
+ );
+ await userEvent.click(
+ screen.getByRole("button", { name: "Mark read through loaded messages" }),
+ );
+ expect(h.markThrough).not.toHaveBeenCalled();
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Load a verified message before marking through it.",
+ );
+});
diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx
index aa09520d..ec3f7c35 100644
--- a/src/features/messages/MessageComposer.test.tsx
+++ b/src/features/messages/MessageComposer.test.tsx
@@ -25,6 +25,8 @@ import type { RelaySession } from "../relay/session";
import { emojiMatches, type CustomEmoji } from "../relay/emoji";
import { CustomEmoji as CustomEmojiImage } from "../../bundled/emoji/CustomEmoji";
import type { ComposerInputElement } from "./composer-dom";
+import { mentionQuery } from "../../bundled/mentions/mention-query";
+import { emojiQuery } from "../../bundled/emoji/emoji-query";
const first = { pubkey: "a".repeat(64), name: "Honey" };
const second = { pubkey: "b".repeat(64), name: "Honey" };
@@ -46,7 +48,10 @@ afterEach(() => {
delete (HTMLElement.prototype as Partial).scrollIntoView;
});
-function mount(options: Partial = {}) {
+function mount(
+ options: Partial = {},
+ providers?: readonly Contribution[],
+) {
let commands: ComposerToolProps;
const completionRequests: ComposerCompletionProps["publish"][] = [];
function Completion({ publish }: ComposerCompletionProps) {
@@ -68,7 +73,7 @@ function mount(options: Partial = {}) {
: null,
component: Completion,
});
- let completions: readonly Contribution[] = [
+ let completions: readonly Contribution[] = providers ?? [
completion("1"),
];
function Tool(props: ComposerToolProps) {
@@ -236,6 +241,11 @@ it("revokes stale completion publications across editor and ownership lifecycles
expect(h.publish(afterAba, "stale dismissal")).toBe(false);
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
+ h.fill("!blur");
+ const blurred = h.completionRequests.length - 1;
+ act(() => input.blur());
+ expect(h.publish(blurred, "stale blur")).toBe(false);
+
h.fill("!provider");
const oldProvider = h.completionRequests.length - 1;
h.replaceCompletionProvider();
@@ -261,6 +271,79 @@ it("revokes stale completion publications across editor and ownership lifecycles
expect(h.publish(fresh, "stale unmount")).toBe(false);
});
+it.each([undefined, "root"])(
+ "completion preserves surrounding prose and exact recipient intent through emoji for %s",
+ async (threadRootId) => {
+ function Mentions({ publish }: ComposerCompletionProps) {
+ useLayoutEffect(() => {
+ return (
+ publish({
+ items: [first, second].map((mention) => ({
+ id: mention.pubkey,
+ label: mention.pubkey,
+ edit: { mention },
+ })),
+ }) || undefined
+ );
+ }, [publish]);
+ return null;
+ }
+ function Emoji({ publish }: ComposerCompletionProps) {
+ useLayoutEffect(() => {
+ return (
+ publish({
+ items: [{ id: "smile", label: "Smile", edit: { text: "😄" } }],
+ }) || undefined
+ );
+ }, [publish]);
+ return null;
+ }
+ const h = mount(threadRootId ? { threadRootId } : {}, [
+ {
+ id: "mentions",
+ key: "mentions",
+ pluginId: "test",
+ revision: "1",
+ title: "Mentions",
+ order: -10,
+ match: ({ text, start }) => mentionQuery(text, start),
+ component: Mentions,
+ },
+ {
+ id: "emoji",
+ key: "emoji",
+ pluginId: "test",
+ revision: "1",
+ title: "Emoji",
+ match: ({ text, start }) => emojiQuery(text, start),
+ component: Emoji,
+ },
+ ]);
+ h.fill("Before @Ho after");
+ act(() => h.input().setSelectionRange(10, 10));
+ fireEvent.select(h.input());
+ await h.user.click(screen.getByRole("option", { name: second.pubkey }));
+ expect(h.input()).toHaveValue("Before @Honey after");
+ expect(h.input()).toHaveFocus();
+ act(() => h.input().setSelectionRange(20, 20));
+ fireEvent.select(h.input());
+ await h.user.keyboard(" :smile");
+ await h.user.keyboard("{Tab}");
+ expect(h.input()).toHaveValue("Before @Honey after 😄");
+ h.submit();
+ const send = threadRootId ? h.messages.reply : h.messages.send;
+ expect(send.mock.calls[0]?.slice(-2)).toEqual([
+ "Before @Honey after 😄",
+ [second.pubkey],
+ ]);
+ h.fill("@Honey :smile");
+ await h.user.keyboard("{Tab}");
+ expect(h.input()).toHaveValue("@Honey 😄");
+ h.submit();
+ expect(send.mock.calls[1]?.slice(-2)).toEqual(["@Honey 😄", []]);
+ },
+);
+
it.each(["disabled", "readOnly"] as const)(
"rejects late and displayed completion results when the editor becomes %s",
(state) => {
diff --git a/tests/browser/notifications.spec.mjs b/tests/browser/notifications.spec.mjs
index 323f83f8..0f623c54 100644
--- a/tests/browser/notifications.spec.mjs
+++ b/tests/browser/notifications.spec.mjs
@@ -136,40 +136,6 @@ test("real live traffic alerts once; replay/reload stay quiet and choices persis
).toHaveCount(0);
});
-test("explicit Allow releases the first fresh alert; master off preserves categories", async ({
- page,
- app,
-}) => {
- await ready(page, app);
- await page.evaluate(() => {
- window.Notification.permission = "default";
- });
- await page
- .getByRole("button", { name: "Check permission", exact: true })
- .click();
- const row = liveMessage(app, "Permission wait");
- await observed(page, row.id);
- expect(await systemCount(page)).toBe(0);
- expect(await page.evaluate(() => window.notificationRequests)).toBe(0);
- await page
- .getByRole("button", { name: "Allow notifications", exact: true })
- .click();
- await expect.poll(() => systemCount(page)).toBe(1);
- await page.getByRole("switch", { name: "Mentions", exact: true }).uncheck();
- await page
- .getByRole("switch", { name: "Desktop alerts", exact: true })
- .uncheck();
- await page
- .getByRole("switch", { name: "Desktop alerts", exact: true })
- .check();
- await expect(
- page.getByRole("switch", { name: "Mentions", exact: true }),
- ).not.toBeChecked();
- const muted = liveMessage(app, "Disabled category");
- await observed(page, muted.id);
- expect(await systemCount(page)).toBe(1);
-});
-
test("a fully visible incoming row stays quiet without publishing read intent", async ({
page,
app,
@@ -383,32 +349,3 @@ test("an installed producer shares policy and OS click navigation, including aft
await page.evaluate(() => window.fixtureNavigation.snapshot().status),
).toBe("opened");
});
-
-test("asynchronous browser display failure reaches Settings once without redelivery", async ({
- page,
- app,
-}) => {
- await ready(page, app);
- liveMessage(app, "Browser display error");
- await expect.poll(() => systemCount(page)).toBe(1);
- await page.evaluate(() => window.notificationEvents[0].onerror?.());
- await expect(page.getByRole("alert")).toHaveText(
- "The browser could not display a notification.",
- );
- expect(
- await page.evaluate(() => {
- const item = window.notificationEvents[0];
- return {
- closed: item.closed,
- click: item.onclick,
- error: item.onerror,
- close: item.onclose,
- };
- }),
- ).toEqual({ closed: true, click: null, error: null, close: null });
- await page
- .getByRole("button", { name: "Check permission", exact: true })
- .click();
- await page.waitForTimeout(150);
- expect(await systemCount(page)).toBe(1);
-});
diff --git a/tests/browser/typeahead.spec.mjs b/tests/browser/typeahead.spec.mjs
index 69ac6f4a..379e7d34 100644
--- a/tests/browser/typeahead.spec.mjs
+++ b/tests/browser/typeahead.spec.mjs
@@ -71,64 +71,6 @@ for (const mode of ["light", "dark"]) {
}
}
-test("typeahead replaces only the query and publishes selected namesake identity, including replies", async ({
- page,
-}) => {
- const errors = [];
- page.on("pageerror", (e) => errors.push(String(e)));
- const input = await open(page);
- const keys = await page.evaluate(() => ({
- first: window.mentionFixture.first,
- second: window.mentionFixture.second,
- }));
- await input.fill("Before @Ho after");
- await input.evaluate((el) => {
- el.setSelectionRange(10, 10);
- el.dispatchEvent(new Event("select", { bubbles: true }));
- });
- const option = page.getByRole("option", {
- name: `Honey ${keys.second}`,
- exact: true,
- });
- await expect(option).toBeVisible();
- await option.click();
- await expect(input).toBeFocused();
- await expect(input).toHaveJSProperty("value", "Before @Honey after");
- await expect(
- page
- .getByRole("region", { name: "Notification recipients" })
- .getByRole("button"),
- ).toHaveCount(1);
- await input.press("Enter");
- await expect
- .poll(() => page.evaluate(() => window.mentionFixture.publications.length))
- .toBe(1);
- expect(
- await page.evaluate(() =>
- window.mentionFixture.publications[0].tags.filter(([tag]) => tag === "p"),
- ),
- ).toEqual([["p", keys.second]]);
- await page.getByRole("button", { name: "Toggle thread" }).click();
- const reply = page.getByRole("textbox", { name: "Reply to thread" });
- await reply.fill("@Ho");
- await expect(
- page.getByRole("option", { name: `Honey ${keys.first}`, exact: true }),
- ).toBeVisible();
- await page
- .getByRole("option", { name: `Honey ${keys.first}`, exact: true })
- .click();
- await page.evaluate(() =>
- window.mentionFixture.change("disable", "buzz.mentions"),
- );
- await reply.press("Enter");
- await expect
- .poll(() => page.evaluate(() => window.mentionFixture.publications.length))
- .toBe(2);
- const sent = await page.evaluate(() => window.mentionFixture.publications[1]);
- expect(sent.tags).toContainEqual(["e", "a".repeat(64), "", "reply"]);
- expect(sent.tags.filter(([tag]) => tag === "p")).toEqual([["p", keys.first]]);
- expect(errors).toEqual([]);
-});
test("emoji keyboard, Escape, selected text, blur, IME and plugin disable preserve ordinary editing", async ({
page,
}) => {
@@ -291,45 +233,6 @@ test("editable composer exposes its listbox popup relationship only while sugges
await expect(input).toHaveRole("textbox");
}
});
-test("plugin replacement, native blur and composer sessions revoke late publications", async ({
- page,
-}) => {
- await page.goto("/tests/fixtures/typeahead.html");
- const input = page.getByRole("textbox", { name: "Message #Test" });
- const latest = async () => {
- await expect
- .poll(() =>
- page.evaluate(() => window.completionFixture.queries().length),
- )
- .toBeGreaterThan(0);
- return page.evaluate(() => window.completionFixture.queries().length - 1);
- };
- const publish = (index, text = "chosen") =>
- page.evaluate(
- ({ index, text }) =>
- window.completionFixture.publish(index, {
- items: [{ id: text, label: text, edit: { text } }],
- }),
- { index, text },
- );
- await input.fill("!blur");
- const blurred = await latest();
- await page.getByRole("textbox", { name: "Message #Other" }).focus();
- expect(await publish(blurred)).toBe(false);
- await input.focus();
- const removed = await latest();
- await page.evaluate(() => window.completionFixture.change("disable"));
- expect(await publish(removed)).toBe(false);
- await page.evaluate(() => window.completionFixture.change("enable"));
- expect(await publish(removed)).toBe(false);
- await input.fill("!scope");
- const scoped = await latest();
- await page.getByRole("button", { name: "Switch session" }).click();
- expect(await publish(scoped)).toBe(false);
- await expect(
- page.getByRole("textbox", { name: "Message #Test" }),
- ).toHaveJSProperty("value", "");
-});
test("selection follows IDs through reordering and rejected replacement never falls through to send", async ({
page,
}) => {
@@ -792,62 +695,6 @@ test("channel and actual ThreadPanel composers keep separate completion and draf
await expect(thread).toHaveJSProperty("value", "@Fixture Reader ");
});
-test("native read-only state rejects a displayed choice without sending", async ({
- page,
-}) => {
- await page.goto("/tests/fixtures/typeahead.html");
- const input = page.getByRole("textbox", { name: "Message #Test" });
- await input.fill("!readonly");
- const current = await page.evaluate(
- () => window.completionFixture.queries().length - 1,
- );
- await page.evaluate(
- (index) =>
- window.completionFixture.publish(index, {
- items: [{ id: "bad", label: "Bad", edit: { text: "bad" } }],
- }),
- current,
- );
- await expect(page.getByRole("option", { name: "Bad" })).toBeVisible();
- await input.evaluate((el) => {
- el.readOnly = true;
- });
- await input.press("Enter");
- await expect(input).toHaveJSProperty("value", "!readonly");
- expect(
- await page.evaluate(() => window.completionFixture.publications.length),
- ).toBe(0);
-});
-
-test("a later emoji trigger wins after a mention without discarding recipient intent", async ({
- page,
-}) => {
- const input = await open(page);
- const key = await page.evaluate(() => window.mentionFixture.first);
- await input.fill("@Ho");
- await page.getByRole("option", { name: `Honey ${key}`, exact: true }).click();
- await input.pressSequentially(":smile");
- await expect(page.getByRole("option").first()).toContainText(":smile:");
- await input.press("Tab");
- await expect(input).toHaveJSProperty("value", "@Honey 😄");
- await input.press("Enter");
- await expect
- .poll(() => page.evaluate(() => window.mentionFixture.publications.length))
- .toBe(1);
- expect(
- await page.evaluate(() =>
- window.mentionFixture.publications[0].tags.filter(([tag]) => tag === "p"),
- ),
- ).toEqual([["p", key]]);
- await input.fill("@Honey :smile");
- await expect(page.getByRole("option").first()).toContainText(":smile:");
- await input.press("Tab");
- await expect(input).toHaveJSProperty("value", "@Honey 😄");
- await expect(
- page.getByRole("region", { name: "Notification recipients" }),
- ).toHaveCount(0);
-});
-
test("portal bounds hold when the focused composer moves outside the viewport", async ({
page,
}) => {
diff --git a/tests/browser/unread.spec.mjs b/tests/browser/unread.spec.mjs
index d87f3149..084480b6 100644
--- a/tests/browser/unread.spec.mjs
+++ b/tests/browser/unread.spec.mjs
@@ -222,73 +222,3 @@ test("a surviving window publishes a closed window's durable read intent", async
await survivor.close();
}
});
-
-test.describe("explicit mark-through with membership activity", () => {
- test.use({ membershipActivity: true });
-
- for (const activityOnly of [false, true]) {
- test(
- activityOnly
- ? "activity-only history explains the missing message without clearing manual unread"
- : "chat followed by membership activity clears manual unread through the newest chat",
- async ({ page, app }) => {
- // Model only upstream signed history; the app must load and verify it.
- const loaded = app.histories.get("primary/alpha").slice(-4);
- app.histories.set(
- "primary/alpha",
- activityOnly
- ? loaded.filter((event) => event.kind === 40099)
- : loaded,
- );
- const lastChat = loaded.findLast((event) => event.kind === 9);
- await open(page, app);
- await composer(page).focus();
- await expect(
- history(page).locator("[data-membership-row]"),
- ).toHaveCount(1);
- if (!activityOnly) {
- await expect(alpha(page).getByRole("img")).toHaveAttribute(
- "aria-label",
- /^2 observed unread messages/,
- );
- }
- await options(page);
- await page
- .getByRole("button", {
- name: "Mark unread on this device",
- exact: true,
- })
- .click();
- await expect(alpha(page).getByRole("img")).toHaveAttribute(
- "aria-label",
- "Marked unread on this device only",
- );
- const before = await journal(page);
- await page
- .getByRole("button", {
- name: "Mark read through loaded messages",
- exact: true,
- })
- .click();
- if (activityOnly) {
- await expect(page.getByRole("alert")).toHaveText(
- "Load a verified message before marking through it.",
- );
- const after = await journal(page);
- expect(after.localUnread.alpha).toBe(before.localUnread.alpha);
- expect(after.state.frontiers).toEqual(before.state.frontiers);
- expect(after.revision).toBe(before.revision);
- } else {
- await expect
- .poll(async () => (await journal(page)).state.frontiers.alpha)
- .toBe(lastChat.created_at);
- expect((await journal(page)).localUnread.alpha).toBeUndefined();
- await expect(alpha(page).getByRole("img")).toHaveCount(0);
- await expect(page.getByRole("alert")).toHaveCount(0);
- }
- // Do not let the shared fixture's legacy WebKit exception mask this path.
- expect(app.report.errors).toEqual([]);
- },
- );
- }
-});