Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/design-system-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ pill actions, consistent fields and shared states.
another appearance preference.
- Native disclosures remain for persisted channel groups and diagnostic content.
They are disclosures, not application menus; their content and state remain local.
- Avatars, previews, links, mentions, thread summaries and recipient removal retain
their identity and navigation behavior. Shared appearance does not move their data.
- Avatars, previews, links, mentions and thread summaries retain their identity
and navigation ownership. Composer mentions use inert shared InlineChip rendering;
editing or deleting the mention removes its notification intent. Shared appearance
does not move their data.
- Panel marks its surface separately from interactive components. Native product
and plugin content inside it can still receive host defaults.
- Legacy utility names remain available through the host bridge for existing
Expand Down
10 changes: 5 additions & 5 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,9 +374,8 @@ chooser UI belongs in tool plugins: `bundled/emoji` and `bundled/mentions` use t
same `registerTool` contract. No page imports their implementations. Optional numeric
`order` (default zero, lower first; ties by contribution key) keeps visual and
keyboard order stable across asynchronous activation and re-enable. Mentions uses
`-10` to retain its position before default-order tools such as Emoji. The host groups
negative-order tools with selected-recipient avatars, preserving DOM/keyboard order;
this is host layout, not a new plugin contract.
`-10` to retain its position before default-order tools such as Emoji. The host
renders tools in that order without a separate selected-recipient row.

Links, channel references, selected mentions and custom emoji render through shared
message components directly in the editable draft. Display tokens retain the exact authored source;
Expand All @@ -397,8 +396,9 @@ presentation, never recipient resolution. Editing/pasting over an identity span
removes its intent under the existing draft rules.

**User intent outlives the tool that created it.** Disabling Mentions removes its
chooser, not selected recipients, their visible disclosure/removal controls, scoped
drafts or pending messages. The session still owns roster/profile data, membership
chooser, not selected recipients, their inline chips, scoped drafts or pending
messages. Editing or deleting a selected mention removes its notification intent;
there is no separate avatar removal control. The session still owns roster/profile data, membership
checks, signing and publication/retry. Plugins remain trusted same-process code;
revocable editor commands do not sandbox the session capabilities they receive.

Expand Down
4 changes: 4 additions & 0 deletions src/bundled/composer/lab/ComposerCopyPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export function ComposerCopyPreview() {
groups={GROUPS}
onValueChange={setValue}
/>
<p className="text-body-sm text-subtle">
Try mentioning both people named Alice or both agents named Honey.
Adding the second namesake reveals a short public key on both chips.
</p>
<div className="composer-playground-stage">
<MessageComposer key={context.value} {...props} />
</div>
Expand Down
10 changes: 9 additions & 1 deletion src/bundled/composer/lab/fixture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,24 @@ import { writeView } from "../../../shared/view-state";
const empty = () => () => {};
const alice = "a".repeat(64);
const honey = "b".repeat(64);
const otherAlice = "c".repeat(64);
const otherHoney = "d".repeat(64);
const profiles = new Map([
[alice, { name: "Alice" }],
[honey, { name: "Honey", isAgent: true as const }],
[otherAlice, { name: "Alice" }],
[otherHoney, { name: "Honey", isAgent: true as const }],
]);
const emptyList: readonly never[] = [];
const emojiSnapshot = { status: "ready" as const, entries: emptyList };
const channelList = {
status: "ready" as const,
channels: [
{ id: "buzz-design", name: "buzz-design", members: [alice, honey] },
{
id: "buzz-design",
name: "buzz-design",
members: [alice, honey, otherAlice, otherHoney],
},
],
};
const library = { status: "ready" as const, identities: [] };
Expand Down
22 changes: 3 additions & 19 deletions src/features/conversation/ComposerTools.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { Contribution } from "../../plugins/contributions";
import type {
ComposerTool,
Expand All @@ -15,12 +9,9 @@ import { ContributionBoundary, contributionKey } from "./ContributionBoundary";

export function ComposerTools({
registry,
renderLeading,
...props
}: ComposerToolProps & {
registry: ContributionReader<ComposerTool>;
/** Host layout for tools ordered before the default group; preserves DOM order. */
renderLeading?: (tools: ReactNode) => ReactNode;
}) {
const tools = useSyncExternalStore(
registry.subscribe,
Expand All @@ -34,21 +25,14 @@ export function ComposerTools({
(a, b) =>
order(a) - order(b) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0),
);
const render = (tool: Contribution<ComposerTool>) => (
return sorted.map((tool) => (
<ContributionBoundary
key={contributionKey(tool)}
fallback={<span role="status">{tool.title} unavailable</span>}
>
<OwnedTool tool={tool} registry={registry} {...props} />
</ContributionBoundary>
);
if (!renderLeading) return sorted.map(render);
return (
<>
{renderLeading(sorted.filter((tool) => order(tool) < 0).map(render))}
{sorted.filter((tool) => order(tool) >= 0).map(render)}
</>
);
));
}
function OwnedTool({
tool,
Expand Down
166 changes: 147 additions & 19 deletions src/features/messages/MessageComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -517,10 +517,13 @@ it.each([undefined, "root"])(
h.unmount();
h = mount(options);
expect(
screen.getByRole("button", {
name: `Remove mention Honey ${second.pubkey}`,
within(h.input()).getAllByRole("img", {
name: /^Person Honey, public key ending/,
}),
).toBeVisible();
).toHaveLength(2);
expect(
screen.queryByRole("region", { name: "Notification recipients" }),
).not.toBeInTheDocument();
h.submit();
expect(
(root ? h.messages.reply : h.messages.send).mock.calls[0]?.at(-1),
Expand All @@ -536,6 +539,57 @@ it.each([undefined, "root"])(
},
);

// Explicit notification intent must remain visible even where Markdown previews are suppressed.
it.each([
["inline code", "`", " `"],
["fenced code", "```\n", "\n```"],
["indented code", " ", ""],
["image", "![", "](https://example.test/image.png)"],
[
"image reference",
"![",
"][image]\n\n[image]: https://example.test/image.png",
],
["definition", '[image]: https://example.test/image.png "', '"'],
["HTML", "<!-- ", " -->"],
["link label", "[", "](https://example.test)"],
["deep Markdown", "> ".repeat(101), ""],
])(
"discloses selected namesakes in %s before and after restoring a draft",
(_kind, prefix, suffix) => {
let h = mount();
h.fill(`${prefix}@Honey ${suffix}`);
expect(h.input().querySelector(".inline-chip")).toBeNull();
h.submit();
expect(h.messages.send.mock.calls.at(-1)?.at(-1)).toEqual([]);
h.fill(`${prefix}${suffix}`);
h.input().setSelectionRange(prefix.length, prefix.length);
act(() => {
h.commands().insertMention(first);
h.commands().insertMention(second);
});
const text = `${prefix}@Honey @Honey ${suffix}`;
const labels = [
"Person Honey, public key ending c a j",
"Person Honey, public key ending 4 h u",
];
const check = () => {
expect(h.input()).toHaveValue(text);
for (const name of labels)
expect(within(h.input()).getByRole("img", { name })).toBeVisible();
};
check();
h.unmount();
h = mount();
check();
h.submit();
expect(h.messages.send).toHaveBeenCalledWith("channel", text, [
first.pubkey,
second.pubkey,
]);
},
);

it.each([undefined, "root"])(
"keeps an untouched mention when smart punctuation replaces text behind the caret in %s",
(root) => {
Expand Down Expand Up @@ -570,9 +624,7 @@ it.each([undefined, "root"])(
});
expect(input).toHaveValue("@Honey can you see this is’s");
expect(
screen.getByRole("button", {
name: `Remove mention Honey ${first.pubkey}`,
}),
within(input).getByRole("img", { name: "Person Honey" }),
).toBeVisible();
h.submit();
expect(
Expand All @@ -581,18 +633,91 @@ it.each([undefined, "root"])(
},
);

it("deleting a mention or removing its chip removes notification intent", async () => {
it("qualifies both namesakes retroactively without changing source and removes qualifiers with ambiguity", () => {
const h = mount();
act(() => {
h.commands().insertMention(first);
});
expect(
within(h.input()).getByRole("img", { name: "Person Honey" }),
).toBeVisible();
act(() => {
h.commands().insertMention(first);
});
expect(h.input().textContent).not.toContain("npub");
act(() => {
h.commands().insertMention({ ...second, name: "honey" });
});
expect(h.input()).toHaveValue("@Honey @Honey @honey ");
expect(
within(h.input()).getAllByRole("img", {
name: "Person Honey, public key ending c a j",
}),
).toHaveLength(2);
expect(
within(h.input()).getByRole("img", {
name: "Person honey, public key ending 4 h u",
}),
).toHaveTextContent("honey · npub…4hu");
h.input().setSelectionRange(14, 20);
act(() => {
h.commands().insertText("");
});
expect(h.input()).toHaveValue("@Honey @Honey ");
expect(h.input().textContent).not.toContain("npub");
h.submit();
expect(h.messages.send).toHaveBeenCalledWith("channel", "@Honey @Honey ", [
first.pubkey,
first.pubkey,
]);
});

it.each([0, 7])(
"does not replay qualifier motion after removing at %i or restoring a destination draft",
(start) => {
const h = mount();
act(() => {
h.commands().insertMention(first);
});
act(() => {
h.commands().insertMention(second);
});
expect(h.input().querySelectorAll("[data-reveal]")).toHaveLength(1);
h.input().setSelectionRange(start, start + 6);
act(() => {
h.commands().insertText("");
});
act(() => {
h.commands().insertMention(start === 0 ? first : second);
});
expect(h.input().querySelectorAll("[data-reveal]")).toHaveLength(0);
h.retarget({ channelId: "other" });
act(() => {
h.commands().insertMention(first);
});
h.retarget({ channelId: "channel" });
expect(h.input().querySelectorAll(".inline-chip-qualifier")).toHaveLength(
2,
);
expect(h.input().querySelectorAll("[data-reveal]")).toHaveLength(0);
},
);

it("replacing an inline mention with ordinary prose removes notification intent", async () => {
const h = mount();
await h.user.click(screen.getByRole("button", { name: "First Honey" }));
h.fill("no recipient now");
h.submit();
expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([]);
await h.user.click(screen.getByRole("button", { name: "First Honey" }));
await h.user.click(
screen.getByRole("button", {
name: `Remove mention Honey ${first.pubkey}`,
}),
);
expect(
within(h.input()).getByRole("img", { name: "Person Honey" }),
).toBeVisible();
h.input().setSelectionRange(0, 6);
act(() => {
h.commands().insertText("Honey");
});
expect(within(h.input()).queryByRole("img")).not.toBeInTheDocument();
h.submit();
expect(h.messages.send.mock.calls[1]?.at(-1)).toEqual([]);
});
Expand Down Expand Up @@ -1190,20 +1315,23 @@ it.each([undefined, "root"])(
]);
expect(h.input()).toHaveValue("@Honey ");
expect(
screen.getAllByRole("button", { name: /^Remove mention/ }),
within(h.input()).getAllByRole("img", { name: "Agent Honey" }),
).toHaveLength(1);
expect(
h.input().querySelector("button, a, [tabindex], [title]"),
).toBeNull();
h.retarget({ channelId: "other" });
expect(h.input()).toHaveValue("");
h.retarget({ channelId: "channel" });
expect(h.input()).toHaveValue("@Honey ");
h.submit();
expect(send.mock.calls.at(-1)?.at(-1)).toEqual([second.pubkey]);
fireEvent.click(
screen.getByRole("button", {
name: `Remove mention Honey ${second.pubkey}`,
}),
);
expect(h.input()).toHaveValue("@Honey ");
h.input().setSelectionRange(0, 6);
act(() => {
h.commands().insertText("Honey");
});
expect(h.input()).toHaveValue("Honey ");
expect(within(h.input()).queryByRole("img")).not.toBeInTheDocument();
h.submit();
expect(send.mock.calls.at(-1)?.at(-1)).toEqual([]);
expect(h.input()).toHaveValue("");
Expand Down
Loading
Loading