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

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