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

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