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

3
API.md
View File

@@ -171,6 +171,9 @@ thumbnail, cover or attachment takes the owner's media token as `?token=`.
- `GET /api/chat?before=&after=` — signed in. A page of messages. The chat
itself goes over `/ws`.
- A message from a moderator or an admin (`chat:everyone`) that says
`@everyone` notifies every user who doesn't have the chat open, and comes
flagged `mentionsEveryone`. No account may be named `everyone`.
### invites

View File

@@ -29,6 +29,7 @@ import { up as up0019UserSettings } from "./migrations/0019_user_settings.ts";
import { up as up0020DigestSchedule } from "./migrations/0020_digest_schedule.ts";
import { up as up0021PageUrls } from "./migrations/0021_page_urls.ts";
import { up as up0022ReserveCommunity } from "./migrations/0022_reserve_community.ts";
import { up as up0023MentionEveryone } from "./migrations/0023_mention_everyone.ts";
interface Migration {
name: string;
@@ -70,6 +71,7 @@ const MIGRATIONS: Migration[] = [
{ name: "0020_digest_schedule", up: up0020DigestSchedule },
{ name: "0021_page_urls", up: up0021PageUrls },
{ name: "0022_reserve_community", up: up0022ReserveCommunity },
{ name: "0023_mention_everyone", up: up0023MentionEveryone },
];
/**

View File

@@ -0,0 +1,74 @@
/**
* Prepares the chat's `@everyone`: it adds `chat_messages.mentions_everyone`,
* and frees the name `everyone`, which no account may have any more.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
import { EVERYONE, mentionsEveryone } from "../../../shared/mention.ts";
import { ROLE_PERMISSIONS } from "../../../shared/permissions.ts";
/**
* Run once by `runMigrations`. The messages that a moderator or an admin
* already sent with `@everyone` get the flag, so that editing one later
* doesn't notify everyone of an old message; none is notified now. A user
* named `everyone`, in any case, is renamed to the first free `everyone_2`,
* `everyone_3`…, with no alias left behind, and an alias `everyone` is
* dropped. Both are logged.
*/
export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as {
name: string;
}[];
if (!cols.some((c) => c.name === "mentions_everyone")) {
db.exec(
`ALTER TABLE chat_messages ADD COLUMN mentions_everyone INTEGER NOT NULL DEFAULT 0;`,
);
}
const roles = Object.entries(ROLE_PERMISSIONS)
.filter(([, perms]) => perms.includes("chat:everyone"))
.map(([role]) => role);
const messages = db.prepare(
`SELECT m.id, m.body FROM chat_messages m JOIN users u ON u.id = m.user_id
WHERE m.mentions_everyone = 0
AND u.role IN (${roles.map(() => "?").join(", ")})
AND m.body LIKE '%@%';`,
).all(...roles) as { id: string; body: string }[];
const flag = db.prepare(
`UPDATE chat_messages SET mentions_everyone = 1 WHERE id = ?;`,
);
for (const m of messages) {
if (mentionsEveryone(m.body)) flag.run(m.id);
}
const taken = (name: string) =>
!!db.prepare(
`SELECT 1 FROM users WHERE lower(username) = lower(?)
UNION ALL SELECT 1 FROM user_aliases WHERE lower(username) = lower(?);`,
).get(name, name);
const user = db.prepare(
`SELECT id, username FROM users WHERE lower(username) = ?;`,
).get(EVERYONE) as { id: string; username: string } | undefined;
if (user) {
let n = 2;
while (taken(`${EVERYONE}_${n}`)) n++;
const renamed = `${EVERYONE}_${n}`;
db.prepare(`UPDATE users SET username = ? WHERE id = ?;`).run(
renamed,
user.id,
);
console.log(
`[migrate] user "${user.username}" is now "${renamed}": @${EVERYONE} is reserved`,
);
}
const alias = db.prepare(
`DELETE FROM user_aliases WHERE lower(username) = ? RETURNING user_id;`,
).get(EVERYONE) as { user_id: string } | undefined;
if (alias) {
console.log(
`[migrate] the former name "${EVERYONE}" of user ${alias.user_id} is dropped: @${EVERYONE} is reserved`,
);
}
}

View File

@@ -265,6 +265,7 @@ CREATE TABLE chat_messages (
created_at TEXT NOT NULL,
updated_at TEXT,
reply_to_id TEXT,
mentions_everyone INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_chat_messages_created_id ON chat_messages(created_at, id);

View File

@@ -35,6 +35,7 @@ export type HighlightKind =
| "mentionDump"
| "mentionPlaylist"
| "mentionChat"
| "mentionEveryone"
| "comment"
| "playlistFollow"
| "userFollow"
@@ -158,6 +159,8 @@ function highlightSentence(
return i18n._("email.digest.hl.mentionIn", values);
case "mentionPlaylist":
return i18n._("email.digest.hl.mentionPlaylist", values);
case "mentionEveryone":
return i18n._("email.digest.hl.mentionEveryone", values);
case "comment":
return i18n._("email.digest.hl.comment", values);
case "playlistFollow":
@@ -244,7 +247,7 @@ export function renderDigestEmail(
)
.join(" · "),
activity.highlights.map((h) => {
const target = h.kind === "mentionChat"
const target = h.kind === "mentionChat" || h.kind === "mentionEveryone"
? mdLink(
i18n._("email.digest.theChat"),
content.links.chat,

View File

@@ -26,6 +26,8 @@ export interface ChatMessageRow {
reply_to_author: string | null;
/** Cut to chat-service's `REPLY_SNIPPET_LEN` characters. */
reply_to_body: string | null;
/** 1 or 0. */
mentions_everyone: number;
[key: string]: SQLOutputValue;
}
@@ -50,7 +52,8 @@ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
"reply_to_author" in obj &&
(typeof obj.reply_to_author === "string" || obj.reply_to_author === null) &&
"reply_to_body" in obj &&
(typeof obj.reply_to_body === "string" || obj.reply_to_body === null);
(typeof obj.reply_to_body === "string" || obj.reply_to_body === null) &&
"mentions_everyone" in obj && typeof obj.mentions_everyone === "number";
}
/**
@@ -69,5 +72,6 @@ export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
replyToId: row.reply_to_id ?? undefined,
replyToAuthor: row.reply_to_author ?? undefined,
replyToBody: row.reply_to_body ?? undefined,
mentionsEveryone: row.mentions_everyone === 1 || undefined,
};
}

View File

@@ -34,6 +34,7 @@ import {
type WsClient,
} from "../services/ws-service.ts";
import {
chatMessageMentionsEveryone,
createChatMessage,
deleteChatMessage,
getChatReadState,
@@ -42,6 +43,7 @@ import {
} from "../services/chat-service.ts";
import {
getUnreadCount,
notifyEveryone,
notifyMentions,
} from "../services/notification-service.ts";
import { hasPermission } from "../../shared/permissions.ts";
@@ -369,7 +371,8 @@ function optionalString(value: unknown, maxLength: number): string | undefined {
/**
* Handles `chat_send`. It stores the message, broadcasts it, and notifies the
* @mentioned users who aren't reading the chat.
* @mentioned users who aren't reading the chat, and everyone who isn't when
* the message is flagged `mentionsEveryone`.
*/
function handleChatSend(
client: WsClient,
@@ -404,6 +407,9 @@ function handleChatSend(
undefined,
isUserChatOpen,
);
if (message.mentionsEveryone) {
notifyEveryone(userId, message.id, isUserChatOpen);
}
} catch (err) {
sendChatError(
client,
@@ -430,7 +436,8 @@ function canModerateChat(client: WsClient): boolean {
/**
* Handles `chat_edit`. It stores the new body, broadcasts it, and notifies, in
* the author's name, the users the edit newly mentions.
* the author's name, the users the edit newly mentions, and everyone when the
* edit adds a `@everyone` the author may say.
*/
function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
const userId = chatWriter(client);
@@ -441,6 +448,10 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
}
try {
// Only an edit that adds `@everyone` notifies everyone: fixing a typo in
// an announcement mustn't reach those who had the chat open when it was
// sent, nor those who joined since.
const wasEveryone = chatMessageMentionsEveryone(id);
const message = updateChatMessage(
id,
body,
@@ -460,6 +471,9 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
undefined,
isUserChatOpen,
);
if (message.mentionsEveryone && !wasEveryone) {
notifyEveryone(message.userId, message.id, isUserChatOpen);
}
} catch (err) {
sendChatError(
client,

View File

@@ -14,6 +14,9 @@ import { chatMessageRowToApi, isChatMessageRow } from "../model/chat.ts";
import { db } from "../db/connection.ts";
import { linkAttachments } from "./attachment-service.ts";
import { VALIDATION } from "../../shared/validation.ts";
import { mentionsEveryone } from "../../shared/mention.ts";
import { hasPermission } from "../../shared/permissions.ts";
import { isRole } from "../../shared/user.ts";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
@@ -28,7 +31,7 @@ const REPLY_SNIPPET_LEN = 300;
/** The columns of a `ChatMessageRow`, to select over {@link FROM_JOINS}. */
const SELECT_COLS =
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
u.username as author_username, u.avatar_mime as author_avatar_mime,
m.mentions_everyone, u.username as author_username, u.avatar_mime as author_avatar_mime,
ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`;
/**
@@ -51,10 +54,27 @@ function fetchMessage(id: string): ChatMessage {
return chatMessageRowToApi(row);
}
/**
* Returns 1 when `body`, written by `authorId`, reaches everyone: it says
* `@everyone` and the author's role, read now, has `chat:everyone`. Else 0,
* for `chat_messages.mentions_everyone`.
*/
function everyoneFlag(authorId: string, body: string): number {
if (!mentionsEveryone(body)) return 0;
const row = db.prepare(`SELECT role FROM users WHERE id = ?;`).get(
authorId,
) as { role: unknown } | undefined;
return row && isRole(row.role) && hasPermission(row.role, "chat:everyone")
? 1
: 0;
}
/**
* Stores a message from `userId` on a `chat_send`, and claims the pasted images
* it links to. A `replyToId` naming no message is dropped, and the message is
* posted as no reply. The caller broadcasts the result.
* posted as no reply. The message `mentionsEveryone` when it says `@everyone`
* and `userId` may. The caller broadcasts the result, and notifies everyone
* when it is flagged.
*
* @throws {APIException} 400 when the body is blank or longer than
* `VALIDATION.CHAT_MESSAGE_MAX`.
@@ -84,8 +104,9 @@ export function createChatMessage(
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
db.prepare(
`INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`,
).run(id, userId, trimmed, createdAt, replyTo);
`INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id, mentions_everyone)
VALUES (?, ?, ?, ?, ?, ?);`,
).run(id, userId, trimmed, createdAt, replyTo, everyoneFlag(userId, trimmed));
// Unclaimed attachments are deleted as orphans at startup.
linkAttachments(trimmed, "chat", id);
return fetchMessage(id);
@@ -102,10 +123,24 @@ function getMessageOwner(id: string): string {
return row.user_id;
}
/**
* Checks whether message `id` is flagged `mentionsEveryone`, false when there
* is no such message. The WebSocket route reads it before an edit, to notify
* everyone only when the edit adds `@everyone`.
*/
export function chatMessageMentionsEveryone(id: string): boolean {
const row = db.prepare(
`SELECT mentions_everyone FROM chat_messages WHERE id = ?;`,
).get(id) as { mentions_everyone: number } | undefined;
return row?.mentions_everyone === 1;
}
/**
* Replaces a message's body on a `chat_edit`, and claims any image the new body
* links to. `canModerate` lets a moderator edit anyone's message. The caller
* broadcasts the result.
* links to. `canModerate` lets a moderator edit anyone's message. The flag
* `mentionsEveryone` is set again from the new body and the author's role, not
* the editor's: a moderator's edit can't make a user's message reach everyone.
* The caller broadcasts the result.
*
* @throws {APIException} 404 when there is no such message, 403 when the user
* is neither its author nor a moderator, 400 when the body is blank or too
@@ -131,8 +166,10 @@ export function updateChatMessage(
`Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`,
);
}
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)
.run(trimmed, new Date().toISOString(), id);
db.prepare(
`UPDATE chat_messages SET body = ?, updated_at = ?, mentions_everyone = ?
WHERE id = ?;`,
).run(trimmed, new Date().toISOString(), everyoneFlag(ownerId, trimmed), id);
linkAttachments(trimmed, "chat", id);
return fetchMessage(id);
}

View File

@@ -263,7 +263,7 @@ function highlightOf(
};
case "chat":
return {
kind: "mentionChat",
kind: d.everyone ? "mentionEveryone" : "mentionChat",
who: d.mentionerUsername,
quote: quoteOf(
(db.prepare(`SELECT body FROM chat_messages WHERE id = ?;`)

View File

@@ -379,6 +379,50 @@ export function notifyMentions(
}
}
/**
* Notifies every user but its author of chat message `messageId`, which says
* `@everyone`. The WebSocket route calls it, after {@link notifyMentions} so
* that a user also named is told by name, when the message is sent flagged
* `mentionsEveryone`, or when an edit flags it. Each user is notified once per
* message, however often it is edited. `skipUserId` spares the users who have
* the chat open, as for a mention.
*/
export function notifyEveryone(
mentionerUserId: string,
messageId: string,
skipUserId?: (userId: string) => boolean,
): void {
const mentionerRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(mentionerUserId) as { username: string } | undefined;
if (!mentionerRow) return;
const userRows = db.prepare(`SELECT id FROM users WHERE id != ?;`).all(
mentionerUserId,
) as { id: string }[];
// One transaction for the fan-out, as for a dump's followers.
transaction(() => {
for (const row of userRows) {
if (skipUserId?.(row.id)) continue;
createNotification(
row.id,
"user_mentioned",
{
mentionerId: mentionerUserId,
mentionerUsername: mentionerRow.username,
contextType: "chat",
contextId: messageId,
contextTitle: "",
everyone: true,
},
// The key of a mention by name, so a user named too gets one.
`mention:chat:${messageId}:${row.id}`,
);
}
});
}
/**
* Notifies a dump's poster that `commenterId` commented on it, unless they are
* the poster. `createComment` calls it for replies too, but not on an edit.

View File

@@ -33,6 +33,7 @@ import { parseRows } from "../model/rows.ts";
import { isEmailEnabled, sendEmail } from "./email-service.ts";
import { renderWelcomeEmail } from "../mail/welcome.ts";
import { USERNAME_RE, VALIDATION } from "../../shared/validation.ts";
import { EVERYONE } from "../../shared/mention.ts";
/** Selects full `UserRow`s from `users u`, with the inviter's name joined. */
const USER_SELECT =
@@ -43,13 +44,17 @@ const USER_SELECT =
/**
* Throws a 409 when `username` is, in any case, the name or a former name of
* an account other than `exceptUserId`. A former name stays its user's for
* good, so that old links and old @mentions can't be taken over.
* an account other than `exceptUserId`, or `everyone`, which the chat's
* `@everyone` takes. A former name stays its user's for good, so that old
* links and old @mentions can't be taken over.
*/
function assertUsernameAvailable(
username: string,
exceptUserId?: string,
): void {
if (username.toLowerCase() === EVERYONE) {
throw new APIException(409, "Username already exists");
}
const row = db.prepare(
`SELECT id FROM users WHERE lower(username) = lower(?) AND id != ?
UNION ALL

View File

@@ -28,14 +28,14 @@ Deno.test("runMigrations: a fresh database is left as schema.sql made it", () =>
.get() as {
n: number;
};
assertEquals(applied.n, 22);
assertEquals(applied.n, 23);
// Running again applies nothing.
runMigrations(db);
assertEquals(
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
n: number;
}).n,
22,
23,
);
db.close();
});

View File

@@ -0,0 +1,39 @@
import "../../setup.ts";
import { assertEquals } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
import { up as up0023 } from "../../../db/migrations/0023_mention_everyone.ts";
Deno.test("migration 0023 flags past @everyone and frees the name", () => {
const db = new DatabaseSync(":memory:");
db.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT, role TEXT);
CREATE TABLE user_aliases (username TEXT PRIMARY KEY, user_id TEXT);
CREATE TABLE chat_messages (id TEXT PRIMARY KEY, user_id TEXT, body TEXT);
INSERT INTO users VALUES
('m', 'mod', 'moderator'), ('u', 'Everyone', 'user'),
('t', 'everyone_2', 'user'), ('a', 'ann', 'user');
INSERT INTO user_aliases VALUES ('everyone_3', 't'), ('EVERYONE', 'a');
INSERT INTO chat_messages VALUES
('1', 'm', 'hi @everyone'), ('3', 'u', 'hi @everyone'),
('4', 'm', 'hi all');
`);
// In code, it says nothing.
db.prepare(`INSERT INTO chat_messages VALUES ('2', 'm', ?);`).run(
"hi `@everyone`",
);
up0023(db);
up0023(db); // idempotent
const flagged = db.prepare(
`SELECT id FROM chat_messages WHERE mentions_everyone = 1;`,
).all() as { id: string }[];
assertEquals(flagged.map((r) => r.id), ["1"]);
const names = db.prepare(`SELECT id, username FROM users ORDER BY id;`)
.all() as { id: string; username: string }[];
assertEquals(names.find((r) => r.id === "u")?.username, "everyone_4");
const aliases = db.prepare(`SELECT username FROM user_aliases;`).all() as {
username: string;
}[];
assertEquals(aliases.map((r) => r.username), ["everyone_3"]);
db.close();
});

View File

@@ -669,6 +669,19 @@ test("renaming: the old name redirects for good, and nobody else can take it", a
assertEquals((await rename(other.id, old)).status, 409);
// Nor to a current one.
assertEquals((await rename(other.id, renamed)).status, 409);
// Nor to everyone, in any case, which the chat's @everyone takes.
assertEquals((await rename(other.id, "Everyone")).status, 409);
assertEquals(
(await post("/api/users", {
json: {
username: "everyone",
password: PASSWORD,
email: `${uniqueName()}@example.test`,
inviteToken: invite(other.id),
},
})).status,
409,
);
// Signing in takes the current name only.
assertEquals(
(await post("/api/sessions", {

View File

@@ -448,6 +448,73 @@ wsTest(
},
);
wsTest(
"chat: @everyone notifies all who aren't reading, when its author may say it",
async (connect) => {
const mod = await createUser({ role: "moderator" });
const user = await createUser();
const named = await createUser();
const reader = await createUser();
const m = await connect(mod);
const u = await connect(user);
const r = await connect(reader);
r.send({ type: "chat_focus", open: true });
await r.sync();
const mentioned = () =>
Object.fromEntries(
(db.prepare(
`SELECT user_id, data FROM notifications WHERE type = 'user_mentioned';`,
).all() as { user_id: string; data: string }[])
.map((row) => [row.user_id, !!JSON.parse(row.data).everyone]),
);
// A user's @everyone reaches no one.
u.send({ type: "chat_send", body: "@everyone hi" });
const plain = (await m.next("chat_message")).message;
assertEquals(plain.mentionsEveryone, undefined);
assertEquals(mentioned(), {});
// A moderator's reaches all but its author and the reader, and a user
// named too is told by name.
m.send({
type: "chat_send",
body: `@Everyone, and @${named.username}`,
});
const byMod = (x: { message: { userId: string } }) =>
x.message.userId === mod.id;
const sent = (await u.next("chat_message", byMod)).message;
assertEquals(sent.mentionsEveryone, true);
const expected = { [user.id]: true, [named.id]: false };
assertEquals(mentioned(), expected);
// Fixing a typo reaches no one new, not even a newcomer.
const late = await createUser();
m.send({ type: "chat_edit", id: sent.id, body: "@everyone, and all" });
await u.next("chat_message_updated");
assertEquals(mentioned(), expected);
// A moderator's edit can't make a user's message reach everyone.
m.send({ type: "chat_edit", id: plain.id, body: "@everyone edited" });
const edited = (await u.next("chat_message_updated")).message;
assertEquals(edited.mentionsEveryone, undefined);
assertEquals(mentioned(), expected);
// An edit that adds it notifies, the newcomer included.
m.send({ type: "chat_send", body: "soon" });
const draft = (await u.next("chat_message", byMod)).message;
m.send({ type: "chat_edit", id: draft.id, body: "now, @everyone" });
await u.next("chat_message_updated");
assertEquals(
count(
`SELECT COUNT(*) AS n FROM notifications WHERE data LIKE ?;`,
`%${draft.id}%`,
),
3,
);
assertEquals(mentioned()[late.id], true);
},
);
wsTest("chat: typing and read markers", async (connect) => {
const alice = await createUser();
const bob = await createUser();

View File

@@ -1,6 +1,7 @@
import { createUser, db, test } from "../helpers.ts";
import { assertEquals, assertThrows } from "@std/assert";
import {
chatMessageMentionsEveryone,
createChatMessage,
deleteChatMessage,
getChatMessagesAfter,
@@ -161,3 +162,26 @@ test("read state: with no messages at all the marker can't be moved", async () =
insertMessage(other.id, "later", T(5));
assertEquals(getChatReadState(me.id).unread, 1);
});
test("mentionsEveryone: set from the author's role when the body is written", async () => {
const mod = await createUser({ role: "moderator" });
const user = await createUser();
const flagged = createChatMessage(mod.id, "hi @everyone");
assertEquals(flagged.mentionsEveryone, true);
assertEquals(chatMessageMentionsEveryone(flagged.id), true);
assertEquals(
createChatMessage(user.id, "hi @everyone").mentionsEveryone,
undefined,
);
assertEquals(
createChatMessage(mod.id, "`@everyone`").mentionsEveryone,
undefined,
);
assertEquals(chatMessageMentionsEveryone(crypto.randomUUID()), false);
// Demoted, the author's next edit loses it.
db.prepare(`UPDATE users SET role = 'user' WHERE id = ?;`).run(mod.id);
const edited = updateChatMessage(flagged.id, "hi @everyone!", mod.id, false);
assertEquals(edited.mentionsEveryone, undefined);
assertEquals(chatMessageMentionsEveryone(flagged.id), false);
});

File diff suppressed because one or more lines are too long

View File

@@ -14,175 +14,180 @@ msgstr ""
"Plural-Forms: \n"
#. js-lingui-explicit-id
#: api/mail/digest.ts:129
#: api/mail/digest.ts:163
msgid "email.digest.hl.mentionEveryone"
msgstr "{who} mentioned everyone in {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:130
#: src/pages/Community.tsx:49
msgid "digest.count.upvotes"
msgstr "{n, plural, one {upvote} other {upvotes}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:131
#: api/mail/digest.ts:132
msgid "email.digest.count.followers"
msgstr "{n, plural, one {new follower} other {new followers}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:133
#: api/mail/digest.ts:134
#: src/pages/Community.tsx:50
msgid "digest.count.comments"
msgstr "{n, plural, one {comment} other {comments}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:135
#: api/mail/digest.ts:136
msgid "email.digest.count.mentions"
msgstr "{n, plural, one {mention} other {mentions}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:137
#: api/mail/digest.ts:138
msgid "email.digest.count.likes"
msgstr "{n, plural, one {comment like} other {comment likes}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:139
#: api/mail/digest.ts:140
msgid "email.digest.count.playlistFollows"
msgstr "{n, plural, one {playlist follow} other {playlist follows}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:155
#: api/mail/digest.ts:156
msgid "email.digest.hl.mentionComment"
msgstr "{who} mentioned you in a comment on {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:158
#: api/mail/digest.ts:159
msgid "email.digest.hl.mentionIn"
msgstr "{who} mentioned you in {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:160
#: api/mail/digest.ts:161
msgid "email.digest.hl.mentionPlaylist"
msgstr "{who} mentioned you in the playlist {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:162
#: api/mail/digest.ts:165
msgid "email.digest.hl.comment"
msgstr "{who} commented on your dump {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:164
#: api/mail/digest.ts:167
msgid "email.digest.hl.playlistFollow"
msgstr "{who} followed your playlist {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:166
#: api/mail/digest.ts:169
msgid "email.digest.hl.userFollow"
msgstr "{who} started following you"
#. js-lingui-explicit-id
#: api/mail/digest.ts:168
#: api/mail/digest.ts:171
msgid "email.digest.hl.commentLike"
msgstr "{who} liked your comment on {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:172
#: api/mail/digest.ts:175
msgid "email.digest.by"
msgstr "by @{username}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:174
#: api/mail/digest.ts:177
msgid "email.digest.addedTo"
msgstr "added to {playlist}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:175
#: api/mail/digest.ts:178
msgid "email.digest.upload"
msgstr "upload"
#. js-lingui-explicit-id
#: api/mail/digest.ts:178
#: api/mail/digest.ts:181
#: src/pages/Community.tsx:141
msgid "digest.invitedByYou"
msgstr "invited by you"
#. js-lingui-explicit-id
#: api/mail/digest.ts:179
#: api/mail/digest.ts:182
#: src/pages/Community.tsx:143
msgid "digest.invitedBy"
msgstr "invited by @{username}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:180
#: api/mail/digest.ts:183
msgid "email.digest.andMore"
msgstr "and {n} more"
#. js-lingui-explicit-id
#: api/mail/digest.ts:182
#: api/mail/digest.ts:185
msgid "email.digest.playlistMeta"
msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:205
#: api/mail/digest.ts:208
msgid "email.digest.yours"
msgstr "yours"
#. js-lingui-explicit-id
#: api/mail/digest.ts:223
#: api/mail/digest.ts:226
#: src/pages/Settings.tsx:154
msgid "digest.monthly"
msgstr "Monthly recap"
#. js-lingui-explicit-id
#: api/mail/digest.ts:223
#: api/mail/digest.ts:226
#: src/pages/Settings.tsx:145
msgid "digest.weekly"
msgstr "Weekly digest"
#. js-lingui-explicit-id
#: api/mail/digest.ts:231
#: api/mail/digest.ts:234
msgid "email.digest.monthly.title"
msgstr "The month on {site}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:232
#: api/mail/digest.ts:235
msgid "email.digest.weekly.title"
msgstr "Your week, {username}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:240
#: api/mail/digest.ts:243
msgid "email.digest.forYou"
msgstr "For you"
#. js-lingui-explicit-id
#: api/mail/digest.ts:249
#: api/mail/digest.ts:252
msgid "email.digest.theChat"
msgstr "the chat"
#. js-lingui-explicit-id
#: api/mail/digest.ts:264
#: api/mail/digest.ts:267
msgid "email.digest.seeAll"
msgstr "{n, plural, one {See the notification} other {See all # notifications}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:273
#: api/mail/digest.ts:276
msgid "email.digest.network"
msgstr "From people you follow"
#. js-lingui-explicit-id
#: api/mail/digest.ts:279
#: api/mail/digest.ts:282
msgid "email.digest.more"
msgstr "{n} more on your Followed feed"
#. js-lingui-explicit-id
#: api/mail/digest.ts:291
#: api/mail/digest.ts:294
#: src/pages/Community.tsx:127
msgid "digest.topMonth"
msgstr "Top this month"
#. js-lingui-explicit-id
#: api/mail/digest.ts:291
#: api/mail/digest.ts:294
#: src/pages/Community.tsx:126
msgid "digest.topWeek"
msgstr "Top this week"
#. js-lingui-explicit-id
#: api/mail/digest.ts:307
#: api/mail/digest.ts:310
#: src/components/layout/UserMenu.tsx:94
#: src/pages/Community.tsx:73
#: src/pages/Community.tsx:100
@@ -190,113 +195,113 @@ msgid "digest.community"
msgstr "Community"
#. js-lingui-explicit-id
#: api/mail/digest.ts:314
#: api/mail/digest.ts:317
#: src/pages/Community.tsx:48
msgid "digest.stat.dumps"
msgstr "{n, plural, one {dump} other {dumps}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:320
#: api/mail/digest.ts:323
#: src/pages/Community.tsx:53
msgid "digest.stat.chat"
msgstr "{n, plural, one {chat message} other {chat messages}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:329
#: api/mail/digest.ts:332
#: src/pages/Community.tsx:133
msgid "digest.newMembers"
msgstr "New members"
#. js-lingui-explicit-id
#: api/mail/digest.ts:348
#: api/mail/digest.ts:351
#: src/pages/Community.tsx:154
msgid "digest.newPlaylists"
msgstr "New public playlists"
#. js-lingui-explicit-id
#: api/mail/digest.ts:363
#: api/mail/digest.ts:366
msgid "email.digest.inChat"
msgstr "In the chat"
#. js-lingui-explicit-id
#: api/mail/digest.ts:365
#: api/mail/digest.ts:368
msgid "email.digest.chatWeek"
msgstr "{n, plural, one {# message this week} other {# messages this week}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:368
#: api/mail/digest.ts:371
msgid "email.digest.openChat"
msgstr "Open the chat"
#. js-lingui-explicit-id
#: api/mail/digest.ts:377
#: api/mail/digest.ts:380
msgid "email.digest.totals"
msgstr "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}."
#. js-lingui-explicit-id
#: api/mail/digest.ts:383
#: api/mail/digest.ts:386
msgid "email.digest.communityPage"
msgstr "See it all on the community page"
#. js-lingui-explicit-id
#: api/mail/digest.ts:393
#: api/mail/digest.ts:396
msgid "email.digest.monthly.reason"
msgstr "You're getting the monthly recap because it's on in your settings."
#. js-lingui-explicit-id
#: api/mail/digest.ts:394
#: api/mail/digest.ts:397
msgid "email.digest.weekly.reason"
msgstr "You're getting the weekly digest because it's on in your settings."
#. js-lingui-explicit-id
#: api/mail/digest.ts:398
#: api/mail/digest.ts:401
#: src/pages/Unsubscribe.tsx:118
msgid "digest.monthly.off"
msgstr "Turn off monthly recaps"
#. js-lingui-explicit-id
#: api/mail/digest.ts:398
#: api/mail/digest.ts:401
#: src/pages/Unsubscribe.tsx:117
msgid "digest.weekly.off"
msgstr "Turn off weekly digests"
#. js-lingui-explicit-id
#: api/mail/digest.ts:403
#: api/mail/digest.ts:406
msgid "email.settings"
msgstr "Settings"
#. js-lingui-explicit-id
#: api/mail/digest.ts:411
#: api/mail/digest.ts:414
msgid "email.digest.monthly.subject"
msgstr "The month on {site}, {range}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:415
#: api/mail/digest.ts:418
msgid "email.digest.weekly.subject"
msgstr "Your week on {site}, {range}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:438
#: api/mail/digest.ts:441
msgid "email.digest.pre.network"
msgstr "{n, plural, one {# new dump from people you follow} other {# new dumps from people you follow}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:447
#: api/mail/digest.ts:450
msgid "email.digest.pre.topMonth"
msgstr "{n, plural, one {the most upvoted dump of the month} other {the # most upvoted dumps of the month}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:451
#: api/mail/digest.ts:454
msgid "email.digest.pre.members"
msgstr "{n, plural, one {# new member} other {# new members}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:454
#: api/mail/digest.ts:457
msgid "email.digest.pre.playlists"
msgstr "{n, plural, one {# new playlist} other {# new playlists}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:458
#: api/mail/digest.ts:461
msgid "email.digest.pre.topWeek"
msgstr "{n, plural, one {the most upvoted dump of the week} other {the # most upvoted dumps of the week}}"
@@ -401,12 +406,12 @@ msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
#. placeholder {0}: typingNames[0]
#. placeholder {1}: typingNames[1]
#: src/components/chat/ChatModal.tsx:649
#: src/components/chat/ChatModal.tsx:650
msgid "{0} and {1} are typing…"
msgstr "{0} and {1} are typing…"
#. placeholder {0}: typingNames[0]
#: src/components/chat/ChatModal.tsx:647
#: src/components/chat/ChatModal.tsx:648
msgid "{0} is typing…"
msgstr "{0} is typing…"
@@ -425,11 +430,11 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
msgid "{label} ({count})"
msgstr "{label} ({count})"
#: src/pages/Notifications.tsx:499
#: src/pages/Notifications.tsx:508
msgid "{totalUnread, plural, one {# new} other {# new}}"
msgstr "{totalUnread, plural, one {# new} other {# new}}"
#: src/components/chat/ChatModal.tsx:778
#: src/components/chat/ChatModal.tsx:779
msgid "{unseenCount} new messages"
msgstr "{unseenCount} new messages"
@@ -480,7 +485,12 @@ msgid "<0>{0}</0> liked your comment on <1>{1}</1>"
msgstr "<0>{0}</0> liked your comment on <1>{1}</1>"
#. placeholder {0}: d.mentionerUsername
#: src/pages/Notifications.tsx:265
#: src/pages/Notifications.tsx:266
msgid "<0>{0}</0> mentioned everyone in <1>{where}</1>"
msgstr "<0>{0}</0> mentioned everyone in <1>{where}</1>"
#. placeholder {0}: d.mentionerUsername
#: src/pages/Notifications.tsx:274
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
@@ -507,7 +517,7 @@ msgstr "<0>{0}</0> upvoted <1>{1}</1>"
msgid "<0>{0}</0> was added to <1>{1}</1>"
msgstr "<0>{0}</0> was added to <1>{1}</1>"
#: src/components/chat/ChatModal.tsx:777
#: src/components/chat/ChatModal.tsx:778
msgid "1 new message"
msgstr "1 new message"
@@ -597,7 +607,7 @@ msgid "Back to login"
msgstr "Back to login"
#: src/contexts/ws/WSProvider.tsx:395
#: src/contexts/ws/WSProvider.tsx:761
#: src/contexts/ws/WSProvider.tsx:764
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
@@ -620,8 +630,8 @@ msgstr "Cancel"
msgid "Cancel removal"
msgstr "Cancel removal"
#: src/components/chat/ChatModal.tsx:838
#: src/components/chat/ChatModal.tsx:839
#: src/components/chat/ChatModal.tsx:840
msgid "Cancel reply"
msgstr "Cancel reply"
@@ -659,7 +669,7 @@ msgstr "Change password…"
msgid "Change skin tone"
msgstr "Change skin tone"
#: src/components/chat/ChatModal.tsx:738
#: src/components/chat/ChatModal.tsx:739
#: src/components/layout/ChatButton.tsx:30
#: src/components/layout/ChatFab.tsx:54
#: src/components/layout/ChatFab.tsx:55
@@ -709,7 +719,7 @@ msgstr "Color scheme"
msgid "Confirm new password"
msgstr "Confirm new password"
#: src/contexts/ws/WSProvider.tsx:754
#: src/contexts/ws/WSProvider.tsx:757
msgid "Connection lost — this message may not have been sent"
msgstr "Connection lost — this message may not have been sent"
@@ -746,7 +756,7 @@ msgstr "Could not save"
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
#: src/components/chat/ChatModal.tsx:664
#: src/components/chat/ChatModal.tsx:665
msgid "Couldn't load the chat."
msgstr "Couldn't load the chat."
@@ -788,9 +798,9 @@ msgstr "Default tab"
#: src/components/category/CategoryManager.tsx:236
#: src/components/category/CategoryManager.tsx:237
#: src/components/chat/ChatMessageItem.tsx:115
#: src/components/chat/ChatMessageItem.tsx:116
#: src/components/chat/ChatMessageItem.tsx:214
#: src/components/chat/ChatMessageItem.tsx:118
#: src/components/chat/ChatMessageItem.tsx:119
#: src/components/chat/ChatMessageItem.tsx:220
#: src/components/comment/CommentNode.tsx:265
#: src/components/comment/CommentNode.tsx:271
#: src/components/form/DeleteButton.tsx:36
@@ -832,7 +842,7 @@ msgstr "Delete this comment?"
msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone."
#: src/components/chat/ChatMessageItem.tsx:213
#: src/components/chat/ChatMessageItem.tsx:219
msgid "Delete this message?"
msgstr "Delete this message?"
@@ -857,12 +867,12 @@ msgstr "Description (optional)"
msgid "Discard"
msgstr "Discard"
#: src/components/chat/ChatModal.tsx:742
#: src/components/chat/ChatModal.tsx:743
msgid "Disconnected — reconnecting…"
msgstr "Disconnected — reconnecting…"
#: src/components/chat/ChatModal.tsx:814
#: src/components/chat/ChatModal.tsx:815
#: src/components/chat/ChatModal.tsx:816
msgid "Dismiss"
msgstr "Dismiss"
@@ -910,12 +920,12 @@ msgstr "Dumps"
msgid "Dumps ({0})"
msgstr "Dumps ({0})"
#: src/pages/Notifications.tsx:305
#: src/pages/Notifications.tsx:314
msgid "Earlier"
msgstr "Earlier"
#: src/components/chat/ChatMessageItem.tsx:104
#: src/components/chat/ChatMessageItem.tsx:105
#: src/components/chat/ChatMessageItem.tsx:107
#: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:59
@@ -941,14 +951,14 @@ msgstr "Edit email"
msgid "Edit title"
msgstr "Edit title"
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/chat/ChatMessageItem.tsx:131
msgid "edited"
msgstr "edited"
#. placeholder {0}: relativeTime(comment.updatedAt)
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:129
#: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:94
#: src/pages/Dump.tsx:505
@@ -958,7 +968,7 @@ msgstr "edited {0}"
#. placeholder {0}: comment.updatedAt.toLocaleString()
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:125
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:92
#: src/pages/Dump.tsx:503
@@ -1132,8 +1142,8 @@ msgstr "image"
msgid "In collections"
msgstr "In collections"
#: src/components/editor/TextEditor.tsx:573
#: src/components/editor/TextEditor.tsx:575
#: src/components/editor/TextEditor.tsx:579
#: src/components/editor/TextEditor.tsx:581
msgid "Insert emoji"
msgstr "Insert emoji"
@@ -1190,7 +1200,7 @@ msgstr "Link"
msgid "Link or file"
msgstr "Link or file"
#: src/contexts/ws/WSProvider.tsx:760
#: src/contexts/ws/WSProvider.tsx:763
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
@@ -1202,7 +1212,7 @@ msgstr "Live updates unavailable."
msgid "Load more"
msgstr "Load more"
#: src/components/chat/ChatModal.tsx:763
#: src/components/chat/ChatModal.tsx:764
msgid "Load older messages"
msgstr "Load older messages"
@@ -1211,7 +1221,7 @@ msgstr "Load older messages"
msgid "Loading dump…"
msgstr "Loading dump…"
#: src/components/chat/ChatModal.tsx:656
#: src/components/chat/ChatModal.tsx:657
msgid "Loading messages…"
msgstr "Loading messages…"
@@ -1228,7 +1238,7 @@ msgid "Loading profile…"
msgstr "Loading profile…"
#: src/components/category/CategoryManager.tsx:60
#: src/components/chat/ChatModal.tsx:762
#: src/components/chat/ChatModal.tsx:763
#: src/components/editor/EmojiPickerPopup.tsx:125
#: src/components/playlist/PlaylistMembershipPanel.tsx:45
#: src/components/shared/ListState.tsx:38
@@ -1293,7 +1303,7 @@ msgstr "Mute"
msgid "Name"
msgstr "Name"
#: src/components/chat/ChatModal.tsx:691
#: src/components/chat/ChatModal.tsx:692
#: src/components/feed/FeedTabBar.tsx:40
#: src/pages/Settings.tsx:52
msgid "New"
@@ -1349,7 +1359,7 @@ msgstr "No followed playlists yet."
msgid "No invitees yet."
msgstr "No invitees yet."
#: src/components/chat/ChatModal.tsx:675
#: src/components/chat/ChatModal.tsx:676
msgid "No messages yet. Say hello!"
msgstr "No messages yet. Say hello!"
@@ -1366,7 +1376,7 @@ msgstr "No playlists match \"{q}\"."
msgid "No playlists yet."
msgstr "No playlists yet."
#: src/contexts/ws/WSProvider.tsx:929
#: src/contexts/ws/WSProvider.tsx:932
msgid "No reply from the server — this message may not have been sent"
msgstr "No reply from the server — this message may not have been sent"
@@ -1388,13 +1398,13 @@ msgstr "Nothing happened this week."
#: src/components/profile/ProfileDumpsTab.tsx:53
#: src/components/profile/ProfileOverviewTab.tsx:47
#: src/pages/Notifications.tsx:513
#: src/pages/Notifications.tsx:522
msgid "Nothing here yet."
msgstr "Nothing here yet."
#: src/components/layout/NotificationBell.tsx:57
#: src/pages/Notifications.tsx:378
#: src/pages/Notifications.tsx:496
#: src/pages/Notifications.tsx:387
#: src/pages/Notifications.tsx:505
#: src/pages/Settings.tsx:128
msgid "Notifications"
msgstr "Notifications"
@@ -1411,7 +1421,7 @@ msgstr "Off"
msgid "On"
msgstr "On"
#: src/components/editor/TextEditor.tsx:385
#: src/components/editor/TextEditor.tsx:391
msgid "Only images can be dropped here."
msgstr "Only images can be dropped here."
@@ -1594,13 +1604,13 @@ msgstr "Renaming…"
msgid "Replace file"
msgstr "Replace file"
#: src/components/chat/ChatMessageItem.tsx:94
#: src/components/chat/ChatMessageItem.tsx:95
#: src/components/chat/ChatMessageItem.tsx:97
#: src/components/chat/ChatMessageItem.tsx:98
#: src/components/comment/CommentNode.tsx:244
msgid "Reply"
msgstr "Reply"
#: src/components/chat/ChatModal.tsx:826
#: src/components/chat/ChatModal.tsx:827
msgid "Replying to"
msgstr "Replying to"
@@ -1687,7 +1697,7 @@ msgstr "Searching…"
msgid "Seek"
msgstr "Seek"
#: src/components/chat/ChatModal.tsx:866
#: src/components/chat/ChatModal.tsx:868
msgid "Send"
msgstr "Send"
@@ -1715,7 +1725,7 @@ msgstr "Set new password"
msgid "Settings"
msgstr "Settings"
#: src/components/chat/ChatModal.tsx:650
#: src/components/chat/ChatModal.tsx:651
msgid "Several people are typing…"
msgstr "Several people are typing…"
@@ -1727,13 +1737,13 @@ msgstr "Single use, valid for 7 days."
msgid "slug"
msgstr "slug"
#: src/components/editor/TextEditor.tsx:343
#: src/components/editor/TextEditor.tsx:349
msgid "Some images couldn't be uploaded."
msgstr "Some images couldn't be uploaded."
#: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:729
#: src/contexts/ws/WSProvider.tsx:732
#: src/pages/PlaylistDetail.tsx:124
msgid "Something went wrong"
msgstr "Something went wrong"
@@ -1766,7 +1776,7 @@ msgstr "the chat"
msgid "The dump couldn't be removed. Try again."
msgstr "The dump couldn't be removed. Try again."
#: src/components/editor/TextEditor.tsx:344
#: src/components/editor/TextEditor.tsx:350
msgid "The image couldn't be uploaded."
msgstr "The image couldn't be uploaded."
@@ -1825,7 +1835,7 @@ msgstr "Title"
msgid "Title is required."
msgstr "Title is required."
#: src/pages/Notifications.tsx:303
#: src/pages/Notifications.tsx:312
#: src/utils/chat/time.ts:24
msgid "Today"
msgstr "Today"
@@ -1835,7 +1845,7 @@ msgstr "Today"
msgid "Track {0}"
msgstr "Track {0}"
#: src/components/chat/ChatModal.tsx:667
#: src/components/chat/ChatModal.tsx:668
msgid "Try again"
msgstr "Try again"
@@ -1851,7 +1861,7 @@ msgstr "Turn off the weekly digest?"
msgid "Turning off…"
msgstr "Turning off…"
#: src/components/chat/ChatModal.tsx:851
#: src/components/chat/ChatModal.tsx:852
msgid "Type a message…"
msgstr "Type a message…"
@@ -1892,7 +1902,7 @@ msgstr "Upload failed"
msgid "Uploading {0}%"
msgstr "Uploading {0}%"
#: src/components/editor/TextEditor.tsx:595
#: src/components/editor/TextEditor.tsx:601
msgid "Uploading image…"
msgstr "Uploading image…"
@@ -1990,7 +2000,7 @@ msgstr "Why?"
msgid "Write a reply…"
msgstr "Write a reply…"
#: src/pages/Notifications.tsx:304
#: src/pages/Notifications.tsx:313
#: src/utils/chat/time.ts:27
msgid "Yesterday"
msgstr "Yesterday"
@@ -2007,11 +2017,11 @@ msgstr "You won't get the monthly recap any more. You can turn it back on in you
msgid "You won't get the weekly digest any more. You can turn it back on in your settings."
msgstr "You won't get the weekly digest any more. You can turn it back on in your settings."
#: src/pages/Notifications.tsx:516
#: src/pages/Notifications.tsx:525
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
#: src/contexts/ws/WSProvider.tsx:910
#: src/contexts/ws/WSProvider.tsx:913
msgid "You're offline — not sent"
msgstr "You're offline — not sent"

File diff suppressed because one or more lines are too long

View File

@@ -14,175 +14,180 @@ msgstr ""
"Language-Team: \n"
#. js-lingui-explicit-id
#: api/mail/digest.ts:129
#: api/mail/digest.ts:163
msgid "email.digest.hl.mentionEveryone"
msgstr "{who} a mentionné tout le monde dans {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:130
#: src/pages/Community.tsx:49
msgid "digest.count.upvotes"
msgstr "{n, plural, one {vote} other {votes}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:131
#: api/mail/digest.ts:132
msgid "email.digest.count.followers"
msgstr "{n, plural, one {nouvel abonné} other {nouveaux abonnés}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:133
#: api/mail/digest.ts:134
#: src/pages/Community.tsx:50
msgid "digest.count.comments"
msgstr "{n, plural, one {commentaire} other {commentaires}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:135
#: api/mail/digest.ts:136
msgid "email.digest.count.mentions"
msgstr "{n, plural, one {mention} other {mentions}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:137
#: api/mail/digest.ts:138
msgid "email.digest.count.likes"
msgstr "{n, plural, one {j'aime sur vos commentaires} other {j'aime sur vos commentaires}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:139
#: api/mail/digest.ts:140
msgid "email.digest.count.playlistFollows"
msgstr "{n, plural, one {abonnement à vos collections} other {abonnements à vos collections}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:155
#: api/mail/digest.ts:156
msgid "email.digest.hl.mentionComment"
msgstr "{who} vous a mentionné dans un commentaire sur {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:158
#: api/mail/digest.ts:159
msgid "email.digest.hl.mentionIn"
msgstr "{who} vous a mentionné dans {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:160
#: api/mail/digest.ts:161
msgid "email.digest.hl.mentionPlaylist"
msgstr "{who} vous a mentionné dans la collection {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:162
#: api/mail/digest.ts:165
msgid "email.digest.hl.comment"
msgstr "{who} a commenté votre reco {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:164
#: api/mail/digest.ts:167
msgid "email.digest.hl.playlistFollow"
msgstr "{who} a suivi votre collection {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:166
#: api/mail/digest.ts:169
msgid "email.digest.hl.userFollow"
msgstr "{who} vous suit désormais"
#. js-lingui-explicit-id
#: api/mail/digest.ts:168
#: api/mail/digest.ts:171
msgid "email.digest.hl.commentLike"
msgstr "{who} a aimé votre commentaire sur {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:172
#: api/mail/digest.ts:175
msgid "email.digest.by"
msgstr "par @{username}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:174
#: api/mail/digest.ts:177
msgid "email.digest.addedTo"
msgstr "ajoutée à {playlist}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:175
#: api/mail/digest.ts:178
msgid "email.digest.upload"
msgstr "fichier"
#. js-lingui-explicit-id
#: api/mail/digest.ts:178
#: api/mail/digest.ts:181
#: src/pages/Community.tsx:141
msgid "digest.invitedByYou"
msgstr "sur votre invitation"
#. js-lingui-explicit-id
#: api/mail/digest.ts:179
#: api/mail/digest.ts:182
#: src/pages/Community.tsx:143
msgid "digest.invitedBy"
msgstr "sur invitation de @{username}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:180
#: api/mail/digest.ts:183
msgid "email.digest.andMore"
msgstr "et {n} de plus"
#. js-lingui-explicit-id
#: api/mail/digest.ts:182
#: api/mail/digest.ts:185
msgid "email.digest.playlistMeta"
msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:205
#: api/mail/digest.ts:208
msgid "email.digest.yours"
msgstr "à vous"
#. js-lingui-explicit-id
#: api/mail/digest.ts:223
#: api/mail/digest.ts:226
#: src/pages/Settings.tsx:154
msgid "digest.monthly"
msgstr "Récap mensuel"
#. js-lingui-explicit-id
#: api/mail/digest.ts:223
#: api/mail/digest.ts:226
#: src/pages/Settings.tsx:145
msgid "digest.weekly"
msgstr "Résumé hebdomadaire"
#. js-lingui-explicit-id
#: api/mail/digest.ts:231
#: api/mail/digest.ts:234
msgid "email.digest.monthly.title"
msgstr "Le mois sur {site}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:232
#: api/mail/digest.ts:235
msgid "email.digest.weekly.title"
msgstr "Votre semaine, {username}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:240
#: api/mail/digest.ts:243
msgid "email.digest.forYou"
msgstr "Pour vous"
#. js-lingui-explicit-id
#: api/mail/digest.ts:249
#: api/mail/digest.ts:252
msgid "email.digest.theChat"
msgstr "la tribune"
#. js-lingui-explicit-id
#: api/mail/digest.ts:264
#: api/mail/digest.ts:267
msgid "email.digest.seeAll"
msgstr "{n, plural, one {Voir la notification} other {Voir les # notifications}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:273
#: api/mail/digest.ts:276
msgid "email.digest.network"
msgstr "De ceux que vous suivez"
#. js-lingui-explicit-id
#: api/mail/digest.ts:279
#: api/mail/digest.ts:282
msgid "email.digest.more"
msgstr "{n} de plus dans votre fil Suivi"
#. js-lingui-explicit-id
#: api/mail/digest.ts:291
#: api/mail/digest.ts:294
#: src/pages/Community.tsx:127
msgid "digest.topMonth"
msgstr "Les plus votées du mois"
#. js-lingui-explicit-id
#: api/mail/digest.ts:291
#: api/mail/digest.ts:294
#: src/pages/Community.tsx:126
msgid "digest.topWeek"
msgstr "Les plus votées de la semaine"
#. js-lingui-explicit-id
#: api/mail/digest.ts:307
#: api/mail/digest.ts:310
#: src/components/layout/UserMenu.tsx:94
#: src/pages/Community.tsx:73
#: src/pages/Community.tsx:100
@@ -190,113 +195,113 @@ msgid "digest.community"
msgstr "Communauté"
#. js-lingui-explicit-id
#: api/mail/digest.ts:314
#: api/mail/digest.ts:317
#: src/pages/Community.tsx:48
msgid "digest.stat.dumps"
msgstr "{n, plural, one {reco} other {recos}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:320
#: api/mail/digest.ts:323
#: src/pages/Community.tsx:53
msgid "digest.stat.chat"
msgstr "{n, plural, one {message dans la tribune} other {messages dans la tribune}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:329
#: api/mail/digest.ts:332
#: src/pages/Community.tsx:133
msgid "digest.newMembers"
msgstr "Nouveaux membres"
#. js-lingui-explicit-id
#: api/mail/digest.ts:348
#: api/mail/digest.ts:351
#: src/pages/Community.tsx:154
msgid "digest.newPlaylists"
msgstr "Nouvelles collections publiques"
#. js-lingui-explicit-id
#: api/mail/digest.ts:363
#: api/mail/digest.ts:366
msgid "email.digest.inChat"
msgstr "Dans la tribune"
#. js-lingui-explicit-id
#: api/mail/digest.ts:365
#: api/mail/digest.ts:368
msgid "email.digest.chatWeek"
msgstr "{n, plural, one {# message cette semaine} other {# messages cette semaine}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:368
#: api/mail/digest.ts:371
msgid "email.digest.openChat"
msgstr "Ouvrir la tribune"
#. js-lingui-explicit-id
#: api/mail/digest.ts:377
#: api/mail/digest.ts:380
msgid "email.digest.totals"
msgstr "{dumps, plural, one {# reco} other {# recos}}, {upvotes, plural, one {# vote} other {# votes}} et {comments, plural, one {# commentaire} other {# commentaires}}."
#. js-lingui-explicit-id
#: api/mail/digest.ts:383
#: api/mail/digest.ts:386
msgid "email.digest.communityPage"
msgstr "Tout voir sur la page de la communauté"
#. js-lingui-explicit-id
#: api/mail/digest.ts:393
#: api/mail/digest.ts:396
msgid "email.digest.monthly.reason"
msgstr "Vous recevez ce récap mensuel car il est activé dans vos paramètres."
#. js-lingui-explicit-id
#: api/mail/digest.ts:394
#: api/mail/digest.ts:397
msgid "email.digest.weekly.reason"
msgstr "Vous recevez ce résumé hebdomadaire car il est activé dans vos paramètres."
#. js-lingui-explicit-id
#: api/mail/digest.ts:398
#: api/mail/digest.ts:401
#: src/pages/Unsubscribe.tsx:118
msgid "digest.monthly.off"
msgstr "Ne plus recevoir le récap mensuel"
#. js-lingui-explicit-id
#: api/mail/digest.ts:398
#: api/mail/digest.ts:401
#: src/pages/Unsubscribe.tsx:117
msgid "digest.weekly.off"
msgstr "Ne plus recevoir le résumé hebdomadaire"
#. js-lingui-explicit-id
#: api/mail/digest.ts:403
#: api/mail/digest.ts:406
msgid "email.settings"
msgstr "Paramètres"
#. js-lingui-explicit-id
#: api/mail/digest.ts:411
#: api/mail/digest.ts:414
msgid "email.digest.monthly.subject"
msgstr "Le mois sur {site}, {range}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:415
#: api/mail/digest.ts:418
msgid "email.digest.weekly.subject"
msgstr "Votre semaine sur {site}, {range}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:438
#: api/mail/digest.ts:441
msgid "email.digest.pre.network"
msgstr "{n, plural, one {# nouvelle reco de ceux que vous suivez} other {# nouvelles recos de ceux que vous suivez}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:447
#: api/mail/digest.ts:450
msgid "email.digest.pre.topMonth"
msgstr "{n, plural, one {la reco la plus votée du mois} other {les # recos les plus votées du mois}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:451
#: api/mail/digest.ts:454
msgid "email.digest.pre.members"
msgstr "{n, plural, one {# nouveau membre} other {# nouveaux membres}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:454
#: api/mail/digest.ts:457
msgid "email.digest.pre.playlists"
msgstr "{n, plural, one {# nouvelle collection} other {# nouvelles collections}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:458
#: api/mail/digest.ts:461
msgid "email.digest.pre.topWeek"
msgstr "{n, plural, one {la reco la plus votée de la semaine} other {les # recos les plus votées de la semaine}}"
@@ -401,12 +406,12 @@ msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus ancienn
#. placeholder {0}: typingNames[0]
#. placeholder {1}: typingNames[1]
#: src/components/chat/ChatModal.tsx:649
#: src/components/chat/ChatModal.tsx:650
msgid "{0} and {1} are typing…"
msgstr "{0} et {1} sont en train d'écrire…"
#. placeholder {0}: typingNames[0]
#: src/components/chat/ChatModal.tsx:647
#: src/components/chat/ChatModal.tsx:648
msgid "{0} is typing…"
msgstr "{0} est en train d'écrire…"
@@ -425,11 +430,11 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
msgid "{label} ({count})"
msgstr "{label} ({count})"
#: src/pages/Notifications.tsx:499
#: src/pages/Notifications.tsx:508
msgid "{totalUnread, plural, one {# new} other {# new}}"
msgstr "{totalUnread, plural, one {# nouvelle} other {# nouvelles}}"
#: src/components/chat/ChatModal.tsx:778
#: src/components/chat/ChatModal.tsx:779
msgid "{unseenCount} new messages"
msgstr "{unseenCount} nouveaux messages"
@@ -480,7 +485,12 @@ msgid "<0>{0}</0> liked your comment on <1>{1}</1>"
msgstr "<0>{0}</0> a aimé votre commentaire sur <1>{1}</1>"
#. placeholder {0}: d.mentionerUsername
#: src/pages/Notifications.tsx:265
#: src/pages/Notifications.tsx:266
msgid "<0>{0}</0> mentioned everyone in <1>{where}</1>"
msgstr "<0>{0}</0> a mentionné tout le monde dans <1>{where}</1>"
#. placeholder {0}: d.mentionerUsername
#: src/pages/Notifications.tsx:274
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
@@ -507,7 +517,7 @@ msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
msgid "<0>{0}</0> was added to <1>{1}</1>"
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
#: src/components/chat/ChatModal.tsx:777
#: src/components/chat/ChatModal.tsx:778
msgid "1 new message"
msgstr "1 nouveau message"
@@ -597,7 +607,7 @@ msgid "Back to login"
msgstr "Retour à la connexion"
#: src/contexts/ws/WSProvider.tsx:395
#: src/contexts/ws/WSProvider.tsx:761
#: src/contexts/ws/WSProvider.tsx:764
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
@@ -620,8 +630,8 @@ msgstr "Annuler"
msgid "Cancel removal"
msgstr "Annuler la suppression"
#: src/components/chat/ChatModal.tsx:838
#: src/components/chat/ChatModal.tsx:839
#: src/components/chat/ChatModal.tsx:840
msgid "Cancel reply"
msgstr "Annuler la réponse"
@@ -659,7 +669,7 @@ msgstr "Changer le mot de passe…"
msgid "Change skin tone"
msgstr "Changer la couleur de peau"
#: src/components/chat/ChatModal.tsx:738
#: src/components/chat/ChatModal.tsx:739
#: src/components/layout/ChatButton.tsx:30
#: src/components/layout/ChatFab.tsx:54
#: src/components/layout/ChatFab.tsx:55
@@ -709,7 +719,7 @@ msgstr "Thème de couleur"
msgid "Confirm new password"
msgstr "Confirmer le nouveau mot de passe"
#: src/contexts/ws/WSProvider.tsx:754
#: src/contexts/ws/WSProvider.tsx:757
msgid "Connection lost — this message may not have been sent"
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
@@ -746,7 +756,7 @@ msgstr "Sauvegarde impossible"
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard."
#: src/components/chat/ChatModal.tsx:664
#: src/components/chat/ChatModal.tsx:665
msgid "Couldn't load the chat."
msgstr "Impossible de charger le chat."
@@ -788,9 +798,9 @@ msgstr "Onglet par défaut"
#: src/components/category/CategoryManager.tsx:236
#: src/components/category/CategoryManager.tsx:237
#: src/components/chat/ChatMessageItem.tsx:115
#: src/components/chat/ChatMessageItem.tsx:116
#: src/components/chat/ChatMessageItem.tsx:214
#: src/components/chat/ChatMessageItem.tsx:118
#: src/components/chat/ChatMessageItem.tsx:119
#: src/components/chat/ChatMessageItem.tsx:220
#: src/components/comment/CommentNode.tsx:265
#: src/components/comment/CommentNode.tsx:271
#: src/components/form/DeleteButton.tsx:36
@@ -832,7 +842,7 @@ msgstr "Supprimer ce commentaire ?"
msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible."
#: src/components/chat/ChatMessageItem.tsx:213
#: src/components/chat/ChatMessageItem.tsx:219
msgid "Delete this message?"
msgstr "Supprimer ce message ?"
@@ -857,12 +867,12 @@ msgstr "Description (facultatif)"
msgid "Discard"
msgstr "Abandonner"
#: src/components/chat/ChatModal.tsx:742
#: src/components/chat/ChatModal.tsx:743
msgid "Disconnected — reconnecting…"
msgstr "Déconnecté — reconnexion…"
#: src/components/chat/ChatModal.tsx:814
#: src/components/chat/ChatModal.tsx:815
#: src/components/chat/ChatModal.tsx:816
msgid "Dismiss"
msgstr "Fermer"
@@ -910,12 +920,12 @@ msgstr "Recos"
msgid "Dumps ({0})"
msgstr "Recos ({0})"
#: src/pages/Notifications.tsx:305
#: src/pages/Notifications.tsx:314
msgid "Earlier"
msgstr "Plus tôt"
#: src/components/chat/ChatMessageItem.tsx:104
#: src/components/chat/ChatMessageItem.tsx:105
#: src/components/chat/ChatMessageItem.tsx:107
#: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:59
@@ -941,14 +951,14 @@ msgstr "Modifier l'e-mail"
msgid "Edit title"
msgstr "Modifier le titre"
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/chat/ChatMessageItem.tsx:131
msgid "edited"
msgstr "modifié"
#. placeholder {0}: relativeTime(comment.updatedAt)
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:129
#: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:94
#: src/pages/Dump.tsx:505
@@ -958,7 +968,7 @@ msgstr "modifié {0}"
#. placeholder {0}: comment.updatedAt.toLocaleString()
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:125
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:92
#: src/pages/Dump.tsx:503
@@ -1132,8 +1142,8 @@ msgstr "image"
msgid "In collections"
msgstr "Dans les collections"
#: src/components/editor/TextEditor.tsx:573
#: src/components/editor/TextEditor.tsx:575
#: src/components/editor/TextEditor.tsx:579
#: src/components/editor/TextEditor.tsx:581
msgid "Insert emoji"
msgstr "Insérer un emoji"
@@ -1190,7 +1200,7 @@ msgstr "Lien"
msgid "Link or file"
msgstr "Lien ou fichier"
#: src/contexts/ws/WSProvider.tsx:760
#: src/contexts/ws/WSProvider.tsx:763
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
@@ -1202,7 +1212,7 @@ msgstr "Mises à jour en direct indisponibles."
msgid "Load more"
msgstr "Charger plus"
#: src/components/chat/ChatModal.tsx:763
#: src/components/chat/ChatModal.tsx:764
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
@@ -1211,7 +1221,7 @@ msgstr "Charger les messages plus anciens"
msgid "Loading dump…"
msgstr "Chargement de la reco…"
#: src/components/chat/ChatModal.tsx:656
#: src/components/chat/ChatModal.tsx:657
msgid "Loading messages…"
msgstr "Chargement des messages…"
@@ -1228,7 +1238,7 @@ msgid "Loading profile…"
msgstr "Chargement du profil…"
#: src/components/category/CategoryManager.tsx:60
#: src/components/chat/ChatModal.tsx:762
#: src/components/chat/ChatModal.tsx:763
#: src/components/editor/EmojiPickerPopup.tsx:125
#: src/components/playlist/PlaylistMembershipPanel.tsx:45
#: src/components/shared/ListState.tsx:38
@@ -1293,7 +1303,7 @@ msgstr "Couper le son"
msgid "Name"
msgstr "Nom"
#: src/components/chat/ChatModal.tsx:691
#: src/components/chat/ChatModal.tsx:692
#: src/components/feed/FeedTabBar.tsx:40
#: src/pages/Settings.tsx:52
msgid "New"
@@ -1349,7 +1359,7 @@ msgstr "Pas encore de collections suivies."
msgid "No invitees yet."
msgstr "Aucun invité pour le moment."
#: src/components/chat/ChatModal.tsx:675
#: src/components/chat/ChatModal.tsx:676
msgid "No messages yet. Say hello!"
msgstr "Aucun message pour l'instant. Dites bonjour !"
@@ -1366,7 +1376,7 @@ msgstr "Aucune collection ne correspond à « {q} »."
msgid "No playlists yet."
msgstr "Pas encore de collections."
#: src/contexts/ws/WSProvider.tsx:929
#: src/contexts/ws/WSProvider.tsx:932
msgid "No reply from the server — this message may not have been sent"
msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé"
@@ -1388,13 +1398,13 @@ msgstr "Rien de neuf cette semaine."
#: src/components/profile/ProfileDumpsTab.tsx:53
#: src/components/profile/ProfileOverviewTab.tsx:47
#: src/pages/Notifications.tsx:513
#: src/pages/Notifications.tsx:522
msgid "Nothing here yet."
msgstr "Rien ici pour l'instant."
#: src/components/layout/NotificationBell.tsx:57
#: src/pages/Notifications.tsx:378
#: src/pages/Notifications.tsx:496
#: src/pages/Notifications.tsx:387
#: src/pages/Notifications.tsx:505
#: src/pages/Settings.tsx:128
msgid "Notifications"
msgstr "Notifications"
@@ -1411,7 +1421,7 @@ msgstr "Désactivés"
msgid "On"
msgstr "Activés"
#: src/components/editor/TextEditor.tsx:385
#: src/components/editor/TextEditor.tsx:391
msgid "Only images can be dropped here."
msgstr "Seules les images peuvent être déposées ici."
@@ -1594,13 +1604,13 @@ msgstr "Renommage…"
msgid "Replace file"
msgstr "Remplacer le fichier"
#: src/components/chat/ChatMessageItem.tsx:94
#: src/components/chat/ChatMessageItem.tsx:95
#: src/components/chat/ChatMessageItem.tsx:97
#: src/components/chat/ChatMessageItem.tsx:98
#: src/components/comment/CommentNode.tsx:244
msgid "Reply"
msgstr "Répondre"
#: src/components/chat/ChatModal.tsx:826
#: src/components/chat/ChatModal.tsx:827
msgid "Replying to"
msgstr "En réponse à"
@@ -1687,7 +1697,7 @@ msgstr "Recherche…"
msgid "Seek"
msgstr "Position"
#: src/components/chat/ChatModal.tsx:866
#: src/components/chat/ChatModal.tsx:868
msgid "Send"
msgstr "Envoyer"
@@ -1715,7 +1725,7 @@ msgstr "Définir un nouveau mot de passe"
msgid "Settings"
msgstr "Paramètres"
#: src/components/chat/ChatModal.tsx:650
#: src/components/chat/ChatModal.tsx:651
msgid "Several people are typing…"
msgstr "Plusieurs personnes écrivent…"
@@ -1727,13 +1737,13 @@ msgstr "Usage unique, valable 7 jours."
msgid "slug"
msgstr "identifiant"
#: src/components/editor/TextEditor.tsx:343
#: src/components/editor/TextEditor.tsx:349
msgid "Some images couldn't be uploaded."
msgstr "Certaines images n'ont pas pu être envoyées."
#: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:729
#: src/contexts/ws/WSProvider.tsx:732
#: src/pages/PlaylistDetail.tsx:124
msgid "Something went wrong"
msgstr "Une erreur est survenue"
@@ -1766,7 +1776,7 @@ msgstr "la tribune"
msgid "The dump couldn't be removed. Try again."
msgstr "Le dump n'a pas pu être retiré. Réessayez."
#: src/components/editor/TextEditor.tsx:344
#: src/components/editor/TextEditor.tsx:350
msgid "The image couldn't be uploaded."
msgstr "L'image n'a pas pu être envoyée."
@@ -1825,7 +1835,7 @@ msgstr "Titre"
msgid "Title is required."
msgstr "Un titre est requis."
#: src/pages/Notifications.tsx:303
#: src/pages/Notifications.tsx:312
#: src/utils/chat/time.ts:24
msgid "Today"
msgstr "Aujourd'hui"
@@ -1835,7 +1845,7 @@ msgstr "Aujourd'hui"
msgid "Track {0}"
msgstr "Piste {0}"
#: src/components/chat/ChatModal.tsx:667
#: src/components/chat/ChatModal.tsx:668
msgid "Try again"
msgstr "Réessayer"
@@ -1851,7 +1861,7 @@ msgstr "Ne plus recevoir le résumé hebdomadaire ?"
msgid "Turning off…"
msgstr "Désactivation…"
#: src/components/chat/ChatModal.tsx:851
#: src/components/chat/ChatModal.tsx:852
msgid "Type a message…"
msgstr "Écrivez un message…"
@@ -1892,7 +1902,7 @@ msgstr "Envoi échoué"
msgid "Uploading {0}%"
msgstr "Envoi {0} %"
#: src/components/editor/TextEditor.tsx:595
#: src/components/editor/TextEditor.tsx:601
msgid "Uploading image…"
msgstr "Envoi de l'image…"
@@ -1990,7 +2000,7 @@ msgstr "Pourquoi ?"
msgid "Write a reply…"
msgstr "Écrire une réponse…"
#: src/pages/Notifications.tsx:304
#: src/pages/Notifications.tsx:313
#: src/utils/chat/time.ts:27
msgid "Yesterday"
msgstr "Hier"
@@ -2007,11 +2017,11 @@ msgstr "Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans
msgid "You won't get the weekly digest any more. You can turn it back on in your settings."
msgstr "Vous ne recevrez plus le résumé hebdomadaire. Vous pouvez le réactiver dans vos paramètres."
#: src/pages/Notifications.tsx:516
#: src/pages/Notifications.tsx:525
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
#: src/contexts/ws/WSProvider.tsx:910
#: src/contexts/ws/WSProvider.tsx:913
msgid "You're offline — not sent"
msgstr "Hors ligne — message non envoyé"

View File

@@ -28,4 +28,11 @@ export interface ChatMessage {
replyToAuthor?: string;
/** The first 300 characters of the replied-to body, in Markdown. */
replyToBody?: string;
/**
* Set when the body says `@everyone` and its author's role had
* `chat:everyone` when it was last written; absent otherwise. The client
* highlights the message for everyone and shows `@everyone` as a mention by
* it, never by the text, which anyone may type.
*/
mentionsEveryone?: boolean;
}

View File

@@ -31,3 +31,20 @@ export function mentionedUsernames(body: string): string[] {
...new Set([...prose.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase())),
];
}
/**
* The name `@everyone` reaches. Said by a role with `chat:everyone` in the
* chat, it notifies every user, so no account may take it
* (`assertUsernameAvailable`).
*/
export const EVERYONE = "everyone";
/**
* Checks whether `body` says `@everyone`, in any case, by the rule of
* {@link mentionedUsernames}. Only the chat service reads it, to set a
* message's `mentionsEveryone` when its author may; the client reads that
* flag, not the text.
*/
export function mentionsEveryone(body: string): boolean {
return mentionedUsernames(body).includes(EVERYONE);
}

View File

@@ -86,7 +86,8 @@ export interface DumpUpvotedData extends DumpRef {
/**
* Carried by a `user_mentioned` notification, sent to a user @mentioned in a
* comment, a dump, a playlist's description or a chat message. Its
* comment, a dump, a playlist's description or a chat message, or to every
* user by a chat message's `@everyone`. Its
* {@link DumpRef} is the dump's, or the comment's dump's; its
* {@link PlaylistRef} the playlist's.
*/
@@ -106,6 +107,11 @@ export interface UserMentionedData extends DumpRef, PlaylistRef {
* notification's link opens.
*/
dumpId?: string;
/**
* Set when the user was reached by `@everyone` rather than by name. A
* message naming them as well notifies them once, by name.
*/
everyone?: true;
}
/** Carried by a `dump_commented` notification, sent to the dump's poster. */

View File

@@ -18,6 +18,7 @@ export type Permission =
| "comment:moderate"
| "playlist:moderate"
| "chat:moderate"
| "chat:everyone"
| "user:manage"
| "category:manage";
@@ -26,13 +27,15 @@ const ALL_PERMISSIONS: readonly Permission[] = [
"comment:moderate",
"playlist:moderate",
"chat:moderate",
"chat:everyone",
"user:manage",
"category:manage",
];
/**
* Read through {@link hasPermission}. Roles are fixed in code. An admin holds
* every permission, and a moderator the moderation ones only.
* every permission, and a moderator the moderation ones and `@everyone` in
* the chat.
*/
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
admin: ALL_PERMISSIONS,
@@ -41,6 +44,7 @@ export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
"comment:moderate",
"playlist:moderate",
"chat:moderate",
"chat:everyone",
],
user: [],
};

View File

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

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,

View File

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

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(

View File

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

View File

@@ -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"))

View File

@@ -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");

View File

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

View File

@@ -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();
});