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:
13
src/App.css
13
src/App.css
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user