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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user