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

@@ -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}