v4: a moderator's or an admin's @everyone in the chat notifies every user — there was no way to reach everyone at once. The new chat:everyone permission, held by moderators and admins, lets a chat message saying @everyone notify every user but its author and those reading the chat, through notifyEveryone, after notifyMentions, so a user also named is told once, by name; the notification is a user_mentioned with everyone set, which the notifications page and the digest word as "mentioned everyone in the chat". chat_messages.mentions_everyone, sent as ChatMessage.mentionsEveryone, is set from the author's role each time the body is written, so a user's @everyone reaches no one, a moderator's edit of a user's message can't make it reach everyone, and a demoted author's next edit loses it. Only an edit that adds @everyone notifies, so fixing an announcement's typo reaches no one new. The client highlights and chimes by the flag, never by the text; Markdown shows @everyone as a mention only in a flagged message, and never links it to a profile. The chat composer offers @everyone first to those who may say it. No account may be named everyone, in any case: migration 0023 adds the column, flags the moderators' and admins' past @everyone messages, renames a user named everyone to everyone_N and drops an alias everyone. 265 backend tests and 681 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots of the chat at 1280 and 390px, with a moderator's and a user's @everyone and the suggestions open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 12:54:09 +00:00
parent 5f239a144d
commit 134d1a0b97
37 changed files with 806 additions and 263 deletions

View File

@@ -13,6 +13,12 @@
.md a:hover {
text-decoration: none;
}
/* A chat message's @everyone: a mention, in the accent, but no link. */
.md .md-everyone {
color: var(--color-accent);
font-style: normal;
font-weight: 700;
}
.md strong {
font-weight: 700;
}
@@ -6909,6 +6915,13 @@ body.has-player .chat-fab {
font-size: var(--text-md);
color: var(--color-text);
}
/* Stands where an avatar would, for @everyone. */
.mention-dropdown-everyone {
width: 20px;
text-align: center;
font-size: 14px;
line-height: 20px;
}
/* ── Emoji picker (frimousse) ── */
/* Portaled to <body> and positioned from JS (see useAnchoredFloat): anchored

View File

@@ -35,7 +35,10 @@ export interface ChatMessageItemProps {
* float over its top.
*/
grouped: boolean;
/** Highlights a message that @mentions the user or replies to them. */
/**
* Highlights a message that @mentions the user, `@everyone` included, or
* replies to them.
*/
mentionsMe: boolean;
/**
* Set when the replied-to message was deleted live, after the server filled
@@ -199,7 +202,10 @@ export function ChatMessageItem(
/>
)
: (
<Markdown className={messageTextClass(message.body)}>
<Markdown
className={messageTextClass(message.body)}
everyone={message.mentionsEveryone}
>
{message.body}
</Markdown>
)}

View File

@@ -143,6 +143,7 @@ export function ChatModal(
} = useWSChat();
const { authFetch, user } = useAuth();
const canModerate = can(user, "chat:moderate");
const canMentionEveryone = can(user, "chat:everyone");
const userId = user?.id;
const [history, setHistory] = useState<ChatMessage[]>([]);
@@ -701,7 +702,7 @@ export function ChatModal(
message={m}
grouped={row.grouped}
mentionsMe={!isOwn && !!username &&
(mentionsUser(m.body, username) ||
(!!m.mentionsEveryone || mentionsUser(m.body, username) ||
m.replyToAuthor === username)}
replyDeleted={!!m.replyToId && deletedIds.has(m.replyToId)}
canEdit={isOwn || canModerate}
@@ -853,6 +854,7 @@ export function ChatModal(
autoResize
maxLength={VALIDATION.CHAT_MESSAGE_MAX}
countFrom={COUNT_FROM}
mentionEveryone={canMentionEveryone}
className="comment-reply-textarea chat-input"
onSubmit={handleSend}
onKeyDown={handleComposerKeyDown}

View File

@@ -11,6 +11,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { userPath } from "../../../shared/paths.ts";
import { EVERYONE } from "../../../shared/mention.ts";
/** Passed to {@link Markdown} by any component that shows a user's text. */
export interface MarkdownProps {
@@ -21,6 +22,12 @@ export interface MarkdownProps {
* would break the layout. The full text is one click away.
*/
inline?: boolean;
/**
* Shows `@everyone` as a mention, from a chat message's
* `mentionsEveryone`. Without it, `@everyone` is plain text: anyone may type
* it, and it reaches no one.
*/
everyone?: boolean;
}
/**
@@ -36,14 +43,18 @@ interface MdNode {
value?: string;
url?: string;
children?: MdNode[];
/** Read by mdast-util-to-hast: the element and attributes to render. */
data?: { hName: string; hProperties: Record<string, string> };
}
/**
* Turns a bare `@username` into a link to that user's profile. It works on the
* parsed tree rather than the source, so code keeps its `@`s and a URL that
* contains one (`https://mastodon.social/@bob`) isn't split in two.
* `@everyone` names no profile: it is left as text, or with `everyone` set,
* becomes a `span.md-everyone`.
*/
function remarkMentions() {
function remarkMentions({ everyone = false }: { everyone?: boolean } = {}) {
const walk = (node: MdNode) => {
if (!node.children) return;
// Link text is already a link, whatever it says.
@@ -56,14 +67,27 @@ function remarkMentions() {
const out: MdNode[] = [];
let last = 0;
for (const m of child.value.matchAll(MENTION_RE)) {
const isEveryone = m[1].toLowerCase() === EVERYONE;
if (isEveryone && !everyone) continue;
if (m.index > last) {
out.push({ type: "text", value: child.value.slice(last, m.index) });
}
out.push({
type: "link",
url: userPath(m[1]),
children: [{ type: "text", value: m[0] }],
});
out.push(
isEveryone
? {
type: "emphasis",
data: {
hName: "span",
hProperties: { className: "md-everyone" },
},
children: [{ type: "text", value: m[0] }],
}
: {
type: "link",
url: userPath(m[1]),
children: [{ type: "text", value: m[0] }],
},
);
last = m.index + m[0].length;
}
if (last === 0) return [child];
@@ -101,6 +125,13 @@ function remarkBreaks() {
const REMARK_PLUGINS = [remarkGfm, remarkMentions, remarkBreaks];
/** {@link REMARK_PLUGINS} for a text whose `@everyone` is a mention. */
const REMARK_PLUGINS_EVERYONE = [
remarkGfm,
[remarkMentions, { everyone: true }],
remarkBreaks,
] as React.ComponentProps<typeof ReactMarkdown>["remarkPlugins"];
/**
* Returns the path of a link into this app, relative or absolute to this
* origin, or null for anywhere else. The API's files and attachments count as
@@ -180,7 +211,7 @@ const INLINE_MARKDOWN_COMPONENTS: Components = {
export const Markdown: React.MemoExoticComponent<
(props: MarkdownProps) => React.JSX.Element
> = memo(function Markdown(
{ children, className, inline = false }: MarkdownProps,
{ children, className, inline = false, everyone = false }: MarkdownProps,
) {
return (
<div
@@ -189,7 +220,7 @@ export const Markdown: React.MemoExoticComponent<
}`}
>
<ReactMarkdown
remarkPlugins={REMARK_PLUGINS}
remarkPlugins={everyone ? REMARK_PLUGINS_EVERYONE : REMARK_PLUGINS}
components={inline ? INLINE_MARKDOWN_COMPONENTS : MARKDOWN_COMPONENTS}
>
{children}

View File

@@ -8,6 +8,7 @@
import { type CSSProperties, useLayoutEffect, useRef } from "react";
import { Avatar } from "../shared/Avatar.tsx";
import type { UserSuggestion } from "../../../shared/user.ts";
import { EVERYONE } from "../../../shared/mention.ts";
/**
* Passed to {@link MentionDropdown} by `TextEditor`, from the state of
@@ -65,12 +66,20 @@ export function MentionDropdown(
onSelect(user.username);
}}
>
<Avatar
userId={user.id}
username={user.username}
hasAvatar={!!user.avatarMime}
size={20}
/>
{user.username === EVERYONE
? (
<span className="mention-dropdown-everyone" aria-hidden="true">
📣
</span>
)
: (
<Avatar
userId={user.id}
username={user.username}
hasAvatar={!!user.avatarMime}
size={20}
/>
)}
<span className="mention-dropdown-username">@{user.username}</span>
</li>
))}

View File

@@ -80,6 +80,11 @@ export interface TextEditorProps {
* before the warning colour does.
*/
countFrom?: number;
/**
* Offers `@everyone` among the mention suggestions. The chat's composer
* sets it for a role with `chat:everyone`, the one place it notifies.
*/
mentionEveryone?: boolean;
"aria-invalid"?: boolean;
/** Joined with the id of the editor's own upload notice when it shows one. */
"aria-describedby"?: string;
@@ -138,6 +143,7 @@ export const TextEditor: React.ForwardRefExoticComponent<
onKeyDown,
maxLength,
countFrom,
mentionEveryone = false,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
},
@@ -224,7 +230,7 @@ export const TextEditor: React.ForwardRefExoticComponent<
closeMention,
handleMentionKeyDown,
handleMentionSelect,
} = useMentionAutocomplete(replace);
} = useMentionAutocomplete(replace, mentionEveryone);
const {
emojiMode,

View File

@@ -671,7 +671,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
if (message.userId !== userIdRef.current) {
if (!isViewingChat()) setUnreadChatCount((prev) => prev + 1);
const username = usernameRef.current;
if (username && mentionsUser(message.body, username)) {
if (
message.mentionsEveryone ||
(username && mentionsUser(message.body, username))
) {
// The server sends no notification for a mention read live, so
// the chime comes from here. Otherwise the notification that
// follows chimes.

View File

@@ -16,7 +16,7 @@ import {
import { API_URL } from "../../config/api.ts";
import { insideCode } from "../../utils/editor/text.ts";
import type { UserSuggestion } from "../../../shared/user.ts";
import { MENTION_RE } from "../../../shared/mention.ts";
import { EVERYONE, MENTION_RE } from "../../../shared/mention.ts";
interface MentionState {
open: boolean;
@@ -37,6 +37,16 @@ const CLOSED: MentionState = {
selectedIndex: 0,
};
/**
* Offered first, to those who may say it, when the query starts `everyone`.
* No user has the name, so `MentionDropdown` tells it apart by it.
*/
const EVERYONE_SUGGESTION: UserSuggestion = {
id: EVERYONE,
username: EVERYONE,
avatarMime: null,
};
/**
* Matches the `@query` ending at the caret by the rule of shared/mention.ts,
* which the server notifies by, so that what is offered would notify.
@@ -100,10 +110,12 @@ export interface UseMentionAutocompleteResult {
* Returns the suggestions' state and the functions that drive them. A pick
* goes through `replace`, `TextEditor`'s function that puts the text over
* `[start, end)` of the textarea's value, followed by a space when `word` is
* set.
* set. With `everyone`, which the chat's composer sets for a role with
* `chat:everyone`, `@everyone` is offered too.
*/
export function useMentionAutocomplete(
replace: (start: number, end: number, text: string, word: boolean) => void,
everyone = false,
): UseMentionAutocompleteResult {
const [state, setStateRaw] = useState<MentionState>(CLOSED);
// Mirrored synchronously, so that the key handler and a late response both
@@ -165,13 +177,15 @@ export function useMentionAutocomplete(
const current = stateRef.current;
// Only the search for what is typed now may open or close the list.
if (current.query !== mention.query) return;
if (body.success && body.data.length > 0) {
setState({
...current,
open: true,
results: body.data,
selectedIndex: 0,
});
const results: UserSuggestion[] = [
...(everyone &&
EVERYONE.startsWith(mention.query.toLowerCase())
? [EVERYONE_SUGGESTION]
: []),
...(body.success ? body.data : []),
];
if (results.length > 0) {
setState({ ...current, open: true, results, selectedIndex: 0 });
} else {
setState({ ...current, open: false, results: [] });
}
@@ -180,7 +194,7 @@ export function useMentionAutocomplete(
}
}, 150);
},
[cancelLookup, close, setState],
[cancelLookup, close, everyone, setState],
);
const select = useCallback(

View File

@@ -261,6 +261,15 @@ function notificationContent(n: Notification): React.ReactNode {
: d.contextType === "chat"
? t`the chat`
: t`a post`);
if (d.everyone) {
return (
<Trans>
<strong>{d.mentionerUsername}</strong>
{" mentioned everyone in "}
<strong>{where}</strong>
</Trans>
);
}
return (
<Trans>
<strong>{d.mentionerUsername}</strong>

View File

@@ -421,6 +421,33 @@ describe("ChatModal: live", () => {
);
});
test("@everyone stands out only when the server flagged it", async () => {
const { socket } = await setup();
for (
const [id, body, mentionsEveryone] of [
["e1", "@everyone typed by anyone", undefined],
["e2", "@everyone from a moderator", true],
] as const
) {
socket.receive({
type: "chat_message",
message: wire(
makeChatMessage({
id,
body,
createdAt: new Date(),
mentionsEveryone,
}),
),
});
}
const row = (text: RegExp) =>
screen.getByText(text).closest(".chat-message");
expect(row(/typed by anyone/)).not.toHaveClass("chat-message--mention");
expect(row(/from a moderator/)).toHaveClass("chat-message--mention");
expect(screen.getByText("@everyone")).toHaveClass("md-everyone");
});
test("an emoji-only message is shown large", async () => {
await setup([makeChatMessage({ body: "🎉🎉" })]);
expect(document.querySelector(".chat-message-text--jumbo"))

View File

@@ -33,6 +33,18 @@ describe("Markdown", () => {
expect(currentPath()).toBe("/@ann");
});
test("@everyone links nowhere, and shows as a mention only when flagged", () => {
md("hi @everyone");
expect(screen.queryByRole("link")).toBeNull();
expect(screen.getByText("hi @everyone")).toBeInTheDocument();
renderWithContexts(<Markdown everyone>{"yo @Everyone and @ann"}</Markdown>);
expect(screen.getByText("@Everyone")).toHaveClass("md-everyone");
expect(screen.getByRole("link", { name: "@ann" })).toHaveAttribute(
"href",
"/@ann",
);
});
test("mentions in code and inside URLs are left alone", () => {
md("`@ann` and https://mastodon.social/@bob");
expect(screen.getByText("@ann").tagName).toBe("CODE");

View File

@@ -29,9 +29,9 @@ describe("useMentionAutocomplete", () => {
{ id: "3", username: "albert", avatarMime: null },
];
function setup() {
function setup(everyone = false) {
const replace = vi.fn();
const hook = renderHook(() => useMentionAutocomplete(replace));
const hook = renderHook(() => useMentionAutocomplete(replace, everyone));
const type = async (value: string, cursor = value.length) => {
act(() => hook.result.current.syncMention(value, cursor, true));
await act(() => vi.advanceTimersByTimeAsync(200));
@@ -56,6 +56,21 @@ describe("useMentionAutocomplete", () => {
);
});
test("offers @everyone first, only when allowed", async () => {
route("GET /api/users", () => [users[0]]);
const allowed = setup(true);
await allowed.type("@E");
expect(allowed.hook.result.current.mentionResults.map((u) => u.username))
.toEqual(["everyone", "alice"]);
await allowed.type("@al");
expect(allowed.hook.result.current.mentionResults.map((u) => u.username))
.toEqual(["alice"]);
const denied = setup();
await denied.type("@e");
expect(denied.hook.result.current.mentionResults.map((u) => u.username))
.toEqual(["alice"]);
});
test("debounces keystrokes into one lookup", async () => {
route("GET /api/users", () => users);
const { hook } = setup();

View File

@@ -134,6 +134,17 @@ const n = {
contextTitle: "",
},
}),
mentionEveryone: makeNotification({
type: "user_mentioned",
data: {
mentionerId: "m",
mentionerUsername: "mo",
contextType: "chat",
contextId: "y",
contextTitle: "",
everyone: true,
},
}),
mentionPlaylist: makeNotification({
type: "user_mentioned",
data: {
@@ -170,8 +181,9 @@ describe("Notifications", () => {
"/notifications?chat=x",
"/@bob/playlists/faves",
]);
expect(await link(/mentioned everyone in/)).toBe("/notifications?chat=y");
expect(screen.getByText("a comment")).toBeInTheDocument();
expect(screen.getByText("the chat")).toBeInTheDocument();
expect(screen.getAllByText("the chat")).toHaveLength(2);
expect(screen.getByText("Faves")).toBeInTheDocument();
});