feat(chat): @everyone mention
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s

Rides the existing `<@id>` token: `everyone` already matches the parser's
`[a-zA-Z0-9_-]+`, so the markdown pre-processor, the inline renderer and the
badge pick it up with no second syntax. User ids are snowflakes, so the
literal `everyone` cannot collide with one.

Gated on a new MENTION_EVERYONE permission rather than being open to all —
otherwise a role can never take it away. Granted to @everyone by default: on
a small instance the ping is a convenience, not a weapon, and an owner who
disagrees can deny it and grant it to a role instead.

Two things that would each have made the gate a no-op:

  - DEFAULT_EVERYONE_PERMISSIONS only applies at space creation, so spaces
    that predate the bit would have had every @everyone mention silently
    stripped. A boot-time backfill ORs the bit into @everyone roles only
    (`id = space_id`), leaving other roles alone so a later denial is not
    overruled on the next restart. Verified against a copy of the production
    database: three roles moved by exactly 2^15, non-@everyone roles
    untouched, second pass a no-op.
  - Enforcement covers edit as well as create; without that, posting a plain
    message and editing it is the way around the permission.

A message that lacks the permission is not rejected — losing what someone
wrote over one token is worse than posting it. The token is demoted to the
literal `@everyone` text they typed: the message reads the same and rings
nobody.

Code spans and fences never ping. People paste payloads and snippets into
chat, and a paste should not ring everyone's client. The same rule decides
the row highlight and the notification sound, so the three cannot disagree.

Verified: web build, server typecheck, 504 web tests, 804 server tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 22:10:56 -03:00
co-authored by Claude Opus 5
parent 1ec5d88fa2
commit a41a944742
16 changed files with 364 additions and 29 deletions
+1
View File
@@ -29,6 +29,7 @@ código e os commits seguem em inglês, como o resto do repositório.
| Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta | | Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta |
| Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira | | Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira |
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações | | Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
| Menção `@everyone` | (ver git log) | Reusa o token `<@id>`; permissão `MENTION_EVERYONE` nova, concedida ao `@everyone` por padrão e retroativa via backfill no boot. Servidor neutraliza o token de quem não tem a permissão, no envio **e** na edição |
| Renomear canal (nome e tópico) | (ver git log) | Rota `PATCH /api/channels/:id` já existia; faltava interface. Normalização em `shared/constants` para servidor e web usarem a mesma regra | | Renomear canal (nome e tópico) | (ver git log) | Rota `PATCH /api/channels/:id` já existia; faltava interface. Normalização em `shared/constants` para servidor e web usarem a mesma regra |
| Volume da tela em 50% por padrão | (ver git log) | `DEFAULT_STREAM_VOLUME`; o publicador reforça o áudio do sistema, então o padrão anterior chegava alto demais | | Volume da tela em 50% por padrão | (ver git log) | `DEFAULT_STREAM_VOLUME`; o publicador reforça o áudio do sistema, então o padrão anterior chegava alto demais |
| Sons 50% mais baixos | (ver git log) | Só `SFX_BASE_VOLUME` (0.8 → 0.4); os `.ogg` não foram regerados | | Sons 50% mais baixos | (ver git log) | Só `SFX_BASE_VOLUME` (0.8 → 0.4); os `.ogg` não foram regerados |
+2 -1
View File
@@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { config } from '../config.js'; import { config } from '../config.js';
import * as schema from './schema.js'; import * as schema from './schema.js';
import { ensureDefaults, backfillOneOnOneDmMembership } from './migrate.js'; import { ensureDefaults, backfillOneOnOneDmMembership, grantMentionEveryoneToLegacyRoles } from './migrate.js';
import { setWorkerId } from '../utils/snowflake.js'; import { setWorkerId } from '../utils/snowflake.js';
import { createSnapshot } from '../utils/backup.js'; import { createSnapshot } from '../utils/backup.js';
import { hasPendingMigrations } from './pendingMigrations.js'; import { hasPendingMigrations } from './pendingMigrations.js';
@@ -53,6 +53,7 @@ export function initDatabase() {
// Recover pre-fix broken 1-on-1 DM threads (deleted partner's membership row // Recover pre-fix broken 1-on-1 DM threads (deleted partner's membership row
// lost before the tombstone fix). Idempotent — safe no-op on every later boot. // lost before the tombstone fix). Idempotent — safe no-op on every later boot.
backfillOneOnOneDmMembership(sqlite); backfillOneOnOneDmMembership(sqlite);
grantMentionEveryoneToLegacyRoles(sqlite);
// Initialize Snowflake worker ID from persisted value // Initialize Snowflake worker ID from persisted value
const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined; const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined;
+41
View File
@@ -87,3 +87,44 @@ export function backfillOneOnOneDmMembership(db: Database.Database): void {
run(); run();
if (restored > 0) console.log(`[backfill] restored ${restored} deleted-partner DM membership row(s)`); if (restored > 0) console.log(`[backfill] restored ${restored} deleted-partner DM membership row(s)`);
} }
/**
* Grant MENTION_EVERYONE on @everyone roles that predate the bit.
*
* `DEFAULT_EVERYONE_PERMISSIONS` only applies at space creation, so spaces
* created before the bit existed carry a stored value without it — and would
* silently have @everyone mentions stripped for every member. This ORs the bit
* in once.
*
* Idempotent by construction: the OR is a no-op when the bit is already set,
* and the WHERE clause skips those rows anyway. Deliberately scoped to the
* @everyone role (`id = space_id`), so an owner who later denies the
* permission is not overruled on the next boot — their denial lives on a
* different row.
*/
export function grantMentionEveryoneToLegacyRoles(db: Database.Database): void {
const BIT = 1n << 15n;
const rows = db.prepare(
`SELECT id, permissions FROM roles WHERE id = space_id`
).all() as { id: string; permissions: string | null }[];
const update = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?');
let changed = 0;
for (const row of rows) {
let current: bigint;
try {
current = BigInt(row.permissions ?? '0');
} catch {
continue; // unparseable value — leave it for a human, do not guess
}
if ((current & BIT) !== 0n) continue;
update.run((current | BIT).toString(), row.id);
changed++;
}
if (changed > 0) {
console.log(`[defaults] Granted MENTION_EVERYONE to ${changed} @everyone role(s)`);
}
}
+28 -2
View File
@@ -16,6 +16,7 @@ import {
} from '@backspace/shared'; } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js'; import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { mentionsEveryone, stripEveryoneMention } from '@backspace/shared/src/mentions.js';
import { fetchEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; import { fetchEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
/** /**
@@ -276,7 +277,8 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
}, },
}, async (request, reply) => { }, async (request, reply) => {
const { id } = request.params; const { id } = request.params;
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body; const { attachments: attachmentIds, replyToId, stickerId } = request.body;
let content = request.body.content;
const spaceId = getChannelSpaceId(id); const spaceId = getChannelSpaceId(id);
if (!spaceId) { if (!spaceId) {
@@ -295,6 +297,18 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
const db0 = getDb(); const db0 = getDb();
const db = db0; const db = db0;
// @everyone is a permission, not a formatting choice. Rather than reject
// the message — which loses what the author wrote over one token — the
// token is demoted to the plain text they typed, so it posts and reads the
// same but rings nobody.
if (
typeof content === 'string' &&
mentionsEveryone(content) &&
!hasPermission(request.userId, spaceId, PermissionBits.MENTION_EVERYONE, id)
) {
content = stripEveryoneMention(content);
}
const hasContent = content && typeof content === 'string' && content.trim().length > 0; const hasContent = content && typeof content === 'string' && content.trim().length > 0;
const hasAttachments = attachmentIds && attachmentIds.length > 0; const hasAttachments = attachmentIds && attachmentIds.length > 0;
@@ -423,9 +437,21 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 }); return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
} }
// Same gate as on create — otherwise editing a message is the way around it.
let edited = content;
if (mentionsEveryone(edited)) {
const editSpaceId = getChannelSpaceId(message.channelId);
if (
!editSpaceId ||
!hasPermission(request.userId, editSpaceId, PermissionBits.MENTION_EVERYONE, message.channelId)
) {
edited = stripEveryoneMention(edited);
}
}
const now = Date.now(); const now = Date.now();
db.update(schema.messages) db.update(schema.messages)
.set({ content: content.trim(), editedAt: now }) .set({ content: edited.trim(), editedAt: now })
.where(eq(schema.messages.id, id)) .where(eq(schema.messages.id, id))
.run(); .run();
+3 -1
View File
@@ -16,7 +16,9 @@
"./src/constants": "./src/constants.ts", "./src/constants": "./src/constants.ts",
"./src/constants.js": "./src/constants.ts", "./src/constants.js": "./src/constants.ts",
"./src/audit": "./src/audit.ts", "./src/audit": "./src/audit.ts",
"./src/audit.js": "./src/audit.ts" "./src/audit.js": "./src/audit.ts",
"./src/mentions": "./src/mentions.ts",
"./src/mentions.js": "./src/mentions.ts"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
+43
View File
@@ -0,0 +1,43 @@
/**
* The @everyone mention.
*
* It rides the existing `<@id>` token shape — `everyone` matches the same
* `[a-zA-Z0-9_-]+` the user-mention parser already accepts — so the markdown
* pre-processor, the inline renderer and the badge all pick it up without a
* second syntax. User ids are snowflakes (digits only), so the literal
* `everyone` can never collide with a real one.
*/
export const EVERYONE_MENTION_ID = 'everyone';
export const EVERYONE_MENTION_TOKEN = `<@${EVERYONE_MENTION_ID}>`;
/** Matches code spans and fences first, so their contents are never a ping. */
const CODE_OR_EVERYONE = /(```[\s\S]*?```|`[^`\n]*`)|<@everyone>/g;
/**
* Whether a message pings everyone.
*
* A token inside a code span or fenced block does not count: people paste
* message payloads and snippets into chat, and a paste should not ring
* everybody's client.
*/
export function mentionsEveryone(content: string | null | undefined): boolean {
if (!content) return false;
CODE_OR_EVERYONE.lastIndex = 0;
for (let m = CODE_OR_EVERYONE.exec(content); m; m = CODE_OR_EVERYONE.exec(content)) {
if (!m[1]) return true; // matched the token itself, not a code span
}
return false;
}
/**
* Neutralises the token into plain text, leaving code spans untouched.
*
* The server applies this when the author lacks MENTION_EVERYONE. Rejecting the
* message instead would lose what they wrote over a formatting detail; this way
* the message still posts, and reads as the literal text they typed.
*/
export function stripEveryoneMention(content: string): string {
return content.replace(CODE_OR_EVERYONE, (match, codeBlock: string | undefined) =>
codeBlock ? codeBlock : '@everyone',
);
}
+5
View File
@@ -16,6 +16,7 @@ export const PermissionBits = {
ATTACH_FILES: 1n << 12n, ATTACH_FILES: 1n << 12n,
READ_MESSAGE_HISTORY: 1n << 13n, READ_MESSAGE_HISTORY: 1n << 13n,
ADD_REACTIONS: 1n << 14n, ADD_REACTIONS: 1n << 14n,
MENTION_EVERYONE: 1n << 15n,
CONNECT: 1n << 20n, CONNECT: 1n << 20n,
SPEAK: 1n << 21n, SPEAK: 1n << 21n,
MUTE_MEMBERS: 1n << 22n, MUTE_MEMBERS: 1n << 22n,
@@ -38,6 +39,10 @@ export const DEFAULT_EVERYONE_PERMISSIONS =
PermissionBits.ATTACH_FILES | PermissionBits.ATTACH_FILES |
PermissionBits.READ_MESSAGE_HISTORY | PermissionBits.READ_MESSAGE_HISTORY |
PermissionBits.ADD_REACTIONS | PermissionBits.ADD_REACTIONS |
// Granted by default: on a small instance the ping is a convenience, not a
// weapon. An owner who disagrees can deny it on @everyone and grant it to a
// role instead — which is why it is a bit rather than being ungated.
PermissionBits.MENTION_EVERYONE |
PermissionBits.STREAM; PermissionBits.STREAM;
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -3,17 +3,21 @@ import type { User } from '@backspace/shared';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useCanonicalUserView } from '../../utils/userViewLookup'; import { useCanonicalUserView } from '../../utils/userViewLookup';
import { EVERYONE_MENTION_ID } from '@backspace/shared/src/mentions';
import { useT } from '../../i18n';
interface MentionBadgeProps { interface MentionBadgeProps {
userId: string; userId: string;
} }
export const MentionBadge = React.memo(function MentionBadge({ userId }: MentionBadgeProps) { export const MentionBadge = React.memo(function MentionBadge({ userId }: MentionBadgeProps) {
const t = useT();
const members = useSpaceStore((s) => s.members); const members = useSpaceStore((s) => s.members);
const spaces = useSpaceStore((s) => s.spaces); const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const openUserProfile = useUIStore((s) => s.openUserProfile); const openUserProfile = useUIStore((s) => s.openUserProfile);
const isEveryone = userId === EVERYONE_MENTION_ID;
const member = members.find((m) => m.userId === userId); const member = members.find((m) => m.userId === userId);
const space = spaces.find((s) => s.id === currentSpaceId); const space = spaces.find((s) => s.id === currentSpaceId);
const ownerId = space?.ownerId; const ownerId = space?.ownerId;
@@ -25,7 +29,12 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention
let displayName: string; let displayName: string;
let color: string; let color: string;
if (member && memberUser) { if (isEveryone) {
// Amber, matching the highlight the mention puts on the message row, so the
// badge and the row read as one signal rather than two unrelated colours.
displayName = t('mention.everyone.label');
color = '#fbbf24';
} else if (member && memberUser) {
displayName = memberUser.displayName ?? memberUser.username; displayName = memberUser.displayName ?? memberUser.username;
if (member.roles && member.roles.length > 0) { if (member.roles && member.roles.length > 0) {
const sorted = [...member.roles].sort((a, b) => b.position - a.position); const sorted = [...member.roles].sort((a, b) => b.position - a.position);
@@ -36,12 +45,12 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention
color = '#7c6cf6'; // accent-primary default color = '#7c6cf6'; // accent-primary default
} }
} else { } else {
displayName = 'Unknown User'; displayName = t('mention.unknownUser');
color = '#a0a0aa'; // text-secondary fallback color = '#a0a0aa'; // text-secondary fallback
} }
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
if (!member || !memberUser) return; if (isEveryone || !member || !memberUser) return;
e.stopPropagation(); e.stopPropagation();
openUserProfile(memberUser, e.currentTarget.getBoundingClientRect()); openUserProfile(memberUser, e.currentTarget.getBoundingClientRect());
}; };
@@ -52,7 +61,9 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention
return ( return (
<span <span
onClick={handleClick} onClick={handleClick}
className="inline-flex items-center rounded-[3px] px-[2px] font-medium cursor-pointer transition-colors hover:brightness-125" className={`inline-flex items-center rounded-[3px] px-[2px] font-medium transition-colors ${
isEveryone ? '' : 'cursor-pointer hover:brightness-125'
}`}
style={{ color, backgroundColor: bgColor }} style={{ color, backgroundColor: bgColor }}
> >
@{displayName} @{displayName}
@@ -6,6 +6,16 @@ import { useSpaceStore } from '../../stores/spaceStore';
import { useFloatingPosition } from '../../hooks/useFloatingPosition'; import { useFloatingPosition } from '../../hooks/useFloatingPosition';
import { useCanonicalUserView } from '../../utils/userViewLookup'; import { useCanonicalUserView } from '../../utils/userViewLookup';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useT } from '../../i18n';
/**
* What the composer gets back when a row is picked. @everyone is not a member,
* so the callback takes a tagged entry rather than a `MemberWithUser` — that
* keeps the two cases apart at the type level instead of by a sentinel id.
*/
export type MentionEntry =
| { kind: 'everyone' }
| { kind: 'member'; member: MemberWithUser };
const MAX_RESULTS = 8; const MAX_RESULTS = 8;
@@ -64,17 +74,60 @@ function MentionMemberRow({
interface MentionPopoverProps { interface MentionPopoverProps {
query: string; query: string;
selectedIndex: number; selectedIndex: number;
onSelect: (member: MemberWithUser) => void; onSelect: (entry: MentionEntry) => void;
anchorRef: React.RefObject<HTMLElement | null>; anchorRef: React.RefObject<HTMLElement | null>;
/** Whether the @everyone row is offered — mirrors MENTION_EVERYONE. */
showEveryone: boolean;
}
function MentionEveryoneRow({
isSelected,
selectedRef,
onSelect,
mobile,
}: {
isSelected: boolean;
selectedRef: React.RefObject<HTMLDivElement>;
onSelect: (entry: MentionEntry) => void;
mobile: boolean;
}) {
const t = useT();
const rowSizing = mobile ? 'gap-3 px-3 py-2.5 min-h-[44px]' : 'gap-2.5 px-2 py-1.5';
return (
<div
ref={isSelected ? selectedRef : undefined}
onClick={() => onSelect({ kind: 'everyone' })}
className={`flex items-center mx-1 rounded cursor-pointer transition-colors ${rowSizing} ${
isSelected ? 'bg-interactive-selected' : 'hover:bg-interactive-hover'
}`}
>
<div
className={`${mobile ? 'w-7 h-7' : 'w-6 h-6'} rounded-full flex items-center justify-center flex-shrink-0 text-[13px] font-bold`}
style={{ color: '#fbbf24', backgroundColor: '#fbbf241a' }}
>
@
</div>
<span
className={`${mobile ? 'text-[15px]' : 'text-[14px]'} font-medium`}
style={{ color: '#fbbf24' }}
>
{t('mention.everyone.label')}
</span>
<span className="text-[12px] text-txt-tertiary truncate">
{t('mention.everyone.description')}
</span>
</div>
);
} }
interface ResolvedListProps { interface ResolvedListProps {
filtered: MemberWithUser[]; filtered: MemberWithUser[];
selectedIndex: number; selectedIndex: number;
selectedRef: React.RefObject<HTMLDivElement>; selectedRef: React.RefObject<HTMLDivElement>;
onSelect: (member: MemberWithUser) => void; onSelect: (entry: MentionEntry) => void;
getMemberColor: (member: MemberWithUser) => string | undefined; getMemberColor: (member: MemberWithUser) => string | undefined;
mobile: boolean; mobile: boolean;
showEveryone: boolean;
} }
function MemberList({ function MemberList({
@@ -84,19 +137,39 @@ function MemberList({
onSelect, onSelect,
getMemberColor, getMemberColor,
mobile, mobile,
showEveryone,
}: ResolvedListProps) { }: ResolvedListProps) {
const t = useT();
// @everyone occupies index 0 when shown, so member rows shift down by one.
// The composer clamps its keyboard selection against the same offset.
const memberOffset = showEveryone ? 1 : 0;
return ( return (
<> <>
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider"> {showEveryone && (
Members <>
</div> <div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
{t('mention.everyone.section')}
</div>
<MentionEveryoneRow
isSelected={selectedIndex === 0}
selectedRef={selectedRef}
onSelect={onSelect}
mobile={mobile}
/>
</>
)}
{filtered.length > 0 && (
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
{t('mention.members')}
</div>
)}
{filtered.map((member, i) => ( {filtered.map((member, i) => (
<MentionMemberRow <MentionMemberRow
key={member.userId} key={member.userId}
member={member} member={member}
isSelected={i === selectedIndex} isSelected={i + memberOffset === selectedIndex}
selectedRef={selectedRef} selectedRef={selectedRef}
onSelect={onSelect} onSelect={(m) => onSelect({ kind: 'member', member: m })}
roleColor={getMemberColor(member)} roleColor={getMemberColor(member)}
mobile={mobile} mobile={mobile}
/> />
@@ -116,6 +189,7 @@ function DesktopMention({
onSelect, onSelect,
anchorRef, anchorRef,
getMemberColor, getMemberColor,
showEveryone,
}: DesktopMentionProps) { }: DesktopMentionProps) {
const selectedRef = useRef<HTMLDivElement>(null); const selectedRef = useRef<HTMLDivElement>(null);
const floatingRef = useRef<HTMLDivElement>(null); const floatingRef = useRef<HTMLDivElement>(null);
@@ -123,7 +197,7 @@ function DesktopMention({
const { style } = useFloatingPosition(anchorRef, floatingRef, { const { style } = useFloatingPosition(anchorRef, floatingRef, {
placement: 'top', placement: 'top',
offset: 4, offset: 4,
enabled: filtered.length > 0, enabled: filtered.length > 0 || showEveryone,
}); });
// Scroll selected item into view // Scroll selected item into view
@@ -141,6 +215,7 @@ function DesktopMention({
onSelect={onSelect} onSelect={onSelect}
getMemberColor={getMemberColor} getMemberColor={getMemberColor}
mobile={false} mobile={false}
showEveryone={showEveryone}
/> />
</div> </div>
</div>, </div>,
@@ -158,6 +233,7 @@ function MobileMention({
selectedIndex, selectedIndex,
onSelect, onSelect,
getMemberColor, getMemberColor,
showEveryone,
}: MobileMentionProps) { }: MobileMentionProps) {
const selectedRef = useRef<HTMLDivElement>(null); const selectedRef = useRef<HTMLDivElement>(null);
@@ -193,6 +269,7 @@ function MobileMention({
onSelect={onSelect} onSelect={onSelect}
getMemberColor={getMemberColor} getMemberColor={getMemberColor}
mobile={true} mobile={true}
showEveryone={showEveryone}
/> />
</div> </div>
</div> </div>
@@ -201,7 +278,7 @@ function MobileMention({
); );
} }
export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: MentionPopoverProps) { export function MentionPopover({ query, selectedIndex, onSelect, anchorRef, showEveryone }: MentionPopoverProps) {
const members = useSpaceStore((s) => s.members); const members = useSpaceStore((s) => s.members);
const spaces = useSpaceStore((s) => s.spaces); const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
@@ -220,7 +297,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
.slice(0, MAX_RESULTS); .slice(0, MAX_RESULTS);
}, [members, query]); }, [members, query]);
if (filtered.length === 0) return null; if (filtered.length === 0 && !showEveryone) return null;
const getMemberColor = (member: MemberWithUser): string | undefined => { const getMemberColor = (member: MemberWithUser): string | undefined => {
if (member.roles && member.roles.length > 0) { if (member.roles && member.roles.length > 0) {
@@ -240,6 +317,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
anchorRef={anchorRef} anchorRef={anchorRef}
filtered={filtered} filtered={filtered}
getMemberColor={getMemberColor} getMemberColor={getMemberColor}
showEveryone={showEveryone}
/> />
); );
} }
@@ -252,6 +330,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
anchorRef={anchorRef} anchorRef={anchorRef}
filtered={filtered} filtered={filtered}
getMemberColor={getMemberColor} getMemberColor={getMemberColor}
showEveryone={showEveryone}
/> />
); );
} }
+7 -2
View File
@@ -31,6 +31,7 @@ import {
type PendingAttachmentView, type PendingAttachmentView,
} from '../../stores/pendingMessageStore'; } from '../../stores/pendingMessageStore';
import { useTransferStore } from '../../stores/transferStore'; import { useTransferStore } from '../../stores/transferStore';
import { mentionsEveryone } from '@backspace/shared/src/mentions';
interface MessageProps { interface MessageProps {
message: MessageWithUser | PendingMessageView; message: MessageWithUser | PendingMessageView;
@@ -419,8 +420,12 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
const replyRoleColor = (msg: { userId: string }) => getMemberDisplayColor(msg.userId); const replyRoleColor = (msg: { userId: string }) => getMemberDisplayColor(msg.userId);
// Self-mention highlighting // Mention highlighting — the same amber row for a direct ping and for
const isMentioned = currentUser && message.content?.includes('<@' + currentUser.id + '>'); // @everyone, because to the reader both mean "this one is for you".
const isMentioned = Boolean(
(currentUser && message.content?.includes('<@' + currentUser.id + '>')) ||
mentionsEveryone(message.content),
);
const content = ( const content = (
<div <div
@@ -3,11 +3,12 @@ import { useT } from '../../i18n';
import { useChatStore } from '../../stores/chatStore'; import { useChatStore } from '../../stores/chatStore';
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore'; import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket'; import { wsSend } from '../../hooks/useWebSocket';
import { MentionPopover } from './MentionPopover'; import { MentionPopover, type MentionEntry } from './MentionPopover';
import { TypingIndicator } from './TypingIndicator'; import { TypingIndicator } from './TypingIndicator';
import { InputPopover, type InputPopoverTab } from './InputPopover'; import { InputPopover, type InputPopoverTab } from './InputPopover';
import { AttachmentProgress } from './AttachmentProgress'; import { AttachmentProgress } from './AttachmentProgress';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { EVERYONE_MENTION_ID, EVERYONE_MENTION_TOKEN } from '@backspace/shared/src/mentions';
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared'; import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
import { useSettingsStore } from '../../stores/settingsStore'; import { useSettingsStore } from '../../stores/settingsStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
@@ -113,6 +114,9 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
const isDm = isDmChannel(channelId); const isDm = isDmChannel(channelId);
const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES); const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES);
const canAttachFiles = isDm || hasPermissionBit(channelPerms, PermissionBits.ATTACH_FILES); const canAttachFiles = isDm || hasPermissionBit(channelPerms, PermissionBits.ATTACH_FILES);
// A DM has no roles to gate on, and pinging "everyone" in a two-person chat
// is noise, so the row is offered in space channels only.
const canMentionEveryone = !isDm && hasPermissionBit(channelPerms, PermissionBits.MENTION_EVERYONE);
// Derive staged transfers from composerStore staged ids + transferStore map // Derive staged transfers from composerStore staged ids + transferStore map
const stagedTransfers: Transfer[] = useMemo(() => { const stagedTransfers: Transfer[] = useMemo(() => {
@@ -207,6 +211,17 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
.slice(0, 8); .slice(0, 8);
}, [members, mentionState]); }, [members, mentionState]);
// The @everyone row is offered while the typed query is still a prefix of the
// word — same rule the member filter uses, so the two lists appear and vanish
// together rather than one lingering.
const showEveryone =
!!mentionState &&
canMentionEveryone &&
EVERYONE_MENTION_ID.startsWith(mentionState.query.toLowerCase());
// Row count for keyboard clamping: @everyone occupies index 0 when shown.
const mentionRowCount = filteredMembers.length + (showEveryone ? 1 : 0);
const handleTyping = useCallback(() => { const handleTyping = useCallback(() => {
if (typingTimeoutRef.current) return; if (typingTimeoutRef.current) return;
const dm = isDmChannel(channelId); const dm = isDmChannel(channelId);
@@ -386,13 +401,14 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
}; };
const selectMention = useCallback( const selectMention = useCallback(
(member: MemberWithUser) => { (entry: MentionEntry) => {
if (!mentionState) return; if (!mentionState) return;
const textarea = textareaRef.current; const textarea = textareaRef.current;
const cursorPos = textarea?.selectionStart ?? draftText.length; const cursorPos = textarea?.selectionStart ?? draftText.length;
const before = draftText.slice(0, mentionState.startIndex); const before = draftText.slice(0, mentionState.startIndex);
const after = draftText.slice(cursorPos); const after = draftText.slice(cursorPos);
const insertion = `<@${member.userId}> `; const insertion =
entry.kind === 'everyone' ? `${EVERYONE_MENTION_TOKEN} ` : `<@${entry.member.userId}> `;
const newContent = before + insertion + after; const newContent = before + insertion + after;
setDraft(channelId, newContent); setDraft(channelId, newContent);
setMentionState(null); setMentionState(null);
@@ -412,11 +428,11 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
const handleKeyDown = (e: React.KeyboardEvent): void => { const handleKeyDown = (e: React.KeyboardEvent): void => {
// Mention popover keyboard navigation // Mention popover keyboard navigation
if (mentionState && filteredMembers.length > 0) { if (mentionState && mentionRowCount > 0) {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
setMentionState((prev) => setMentionState((prev) =>
prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, filteredMembers.length - 1) } : null, prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, mentionRowCount - 1) } : null,
); );
return; return;
} }
@@ -429,8 +445,12 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
} }
if (e.key === 'Enter' || e.key === 'Tab') { if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
const selected = filteredMembers[mentionState.selectedIndex]; if (showEveryone && mentionState.selectedIndex === 0) {
if (selected) selectMention(selected); selectMention({ kind: 'everyone' });
return;
}
const selected = filteredMembers[mentionState.selectedIndex - (showEveryone ? 1 : 0)];
if (selected) selectMention({ kind: 'member', member: selected });
return; return;
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -822,6 +842,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
selectedIndex={mentionState.selectedIndex} selectedIndex={mentionState.selectedIndex}
onSelect={selectMention} onSelect={selectMention}
anchorRef={inputContainerRef} anchorRef={inputContainerRef}
showEveryone={showEveryone}
/> />
)} )}
@@ -37,6 +37,7 @@ const PERMISSION_GROUPS: { nameKey: TranslationKey; perms: PermDef[] }[] = [
{ bit: PermissionBits.ATTACH_FILES, labelKey: 'perm.attachFiles' }, { bit: PermissionBits.ATTACH_FILES, labelKey: 'perm.attachFiles' },
{ bit: PermissionBits.READ_MESSAGE_HISTORY, labelKey: 'perm.readHistory' }, { bit: PermissionBits.READ_MESSAGE_HISTORY, labelKey: 'perm.readHistory' },
{ bit: PermissionBits.ADD_REACTIONS, labelKey: 'perm.addReactions' }, { bit: PermissionBits.ADD_REACTIONS, labelKey: 'perm.addReactions' },
{ bit: PermissionBits.MENTION_EVERYONE, labelKey: 'perm.mentionEveryone' },
], ],
}, },
{ {
+7
View File
@@ -669,6 +669,7 @@ export const en = {
'perm.manageMessages': 'Manage Messages', 'perm.manageMessages': 'Manage Messages',
'perm.attachFiles': 'Attach Files', 'perm.attachFiles': 'Attach Files',
'perm.readHistory': 'Read Message History', 'perm.readHistory': 'Read Message History',
'perm.mentionEveryone': 'Mention @everyone',
'perm.addReactions': 'Add Reactions', 'perm.addReactions': 'Add Reactions',
'perm.connect': 'Connect', 'perm.connect': 'Connect',
'perm.speak': 'Speak', 'perm.speak': 'Speak',
@@ -1019,6 +1020,12 @@ export const en = {
'mobile.voice.shareScreen': 'Share screen', 'mobile.voice.shareScreen': 'Share screen',
'mobile.voice.stopSharing': 'Stop sharing screen', 'mobile.voice.stopSharing': 'Stop sharing screen',
'mobile.voice.disconnect': 'Disconnect from call', 'mobile.voice.disconnect': 'Disconnect from call',
// @everyone mention
'mention.everyone.label': 'everyone',
'mention.everyone.description': 'Notify everyone in this channel',
'mention.everyone.section': 'Special',
'mention.unknownUser': 'Unknown User',
'mention.members': 'Members',
} as const; } as const;
export type TranslationKey = keyof typeof en; export type TranslationKey = keyof typeof en;
+7
View File
@@ -668,6 +668,7 @@ export const ptBR: Partial<Dictionary> = {
'perm.manageMessages': 'Gerenciar mensagens', 'perm.manageMessages': 'Gerenciar mensagens',
'perm.attachFiles': 'Anexar arquivos', 'perm.attachFiles': 'Anexar arquivos',
'perm.readHistory': 'Ler histórico de mensagens', 'perm.readHistory': 'Ler histórico de mensagens',
'perm.mentionEveryone': 'Mencionar @everyone',
'perm.addReactions': 'Adicionar reações', 'perm.addReactions': 'Adicionar reações',
'perm.connect': 'Conectar', 'perm.connect': 'Conectar',
'perm.speak': 'Falar', 'perm.speak': 'Falar',
@@ -1018,4 +1019,10 @@ export const ptBR: Partial<Dictionary> = {
'mobile.voice.shareScreen': 'Compartilhar a tela', 'mobile.voice.shareScreen': 'Compartilhar a tela',
'mobile.voice.stopSharing': 'Parar de compartilhar', 'mobile.voice.stopSharing': 'Parar de compartilhar',
'mobile.voice.disconnect': 'Sair da chamada', 'mobile.voice.disconnect': 'Sair da chamada',
// Menção @everyone
'mention.everyone.label': 'everyone',
'mention.everyone.description': 'Avisa todo mundo neste canal',
'mention.everyone.section': 'Especial',
'mention.unknownUser': 'Usuário desconhecido',
'mention.members': 'Membros',
}; };
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import {
mentionsEveryone,
stripEveryoneMention,
EVERYONE_MENTION_TOKEN,
} from '@backspace/shared/src/mentions';
import { shouldPlayMessageSound } from './notificationFilters';
describe('mentionsEveryone', () => {
it('detects the token', () => {
expect(mentionsEveryone('hey <@everyone> look')).toBe(true);
expect(mentionsEveryone(EVERYONE_MENTION_TOKEN)).toBe(true);
});
it('ignores plain text and other mentions', () => {
expect(mentionsEveryone('everyone is here')).toBe(false);
expect(mentionsEveryone('@everyone')).toBe(false);
expect(mentionsEveryone('<@12345>')).toBe(false);
expect(mentionsEveryone(null)).toBe(false);
expect(mentionsEveryone('')).toBe(false);
});
// People paste payloads and snippets into chat; a paste must not ring
// everyone's client.
it('does not fire from inside a code span or fence', () => {
expect(mentionsEveryone('use `<@everyone>` to ping')).toBe(false);
expect(mentionsEveryone('```\n<@everyone>\n```')).toBe(false);
});
it('still fires when a real token sits beside a code span', () => {
expect(mentionsEveryone('`<@everyone>` and also <@everyone>')).toBe(true);
});
it('is not confused by a stateful regex across calls', () => {
// The matcher is a module-level /g regex; a leaked lastIndex would make the
// second call miss.
expect(mentionsEveryone('<@everyone>')).toBe(true);
expect(mentionsEveryone('<@everyone>')).toBe(true);
});
});
describe('stripEveryoneMention', () => {
it('demotes the token to plain text', () => {
expect(stripEveryoneMention('hi <@everyone> !')).toBe('hi @everyone !');
});
it('leaves code spans untouched', () => {
expect(stripEveryoneMention('`<@everyone>`')).toBe('`<@everyone>`');
});
it('is idempotent', () => {
const once = stripEveryoneMention('<@everyone>');
expect(stripEveryoneMention(once)).toBe(once);
});
it('output no longer reads as a mention', () => {
expect(mentionsEveryone(stripEveryoneMention('<@everyone> hi'))).toBe(false);
});
});
describe('shouldPlayMessageSound with @everyone', () => {
const base = {
authorUserId: 'other',
myIds: new Set(['me']),
isDmChannel: false,
allChannels: false,
};
it('fires on an @everyone ping', () => {
expect(shouldPlayMessageSound({ ...base, content: 'hey <@everyone>' })).toBe(true);
});
it('stays silent for your own @everyone', () => {
expect(
shouldPlayMessageSound({ ...base, authorUserId: 'me', content: '<@everyone>' }),
).toBe(false);
});
it('stays silent when the token is inside code', () => {
expect(shouldPlayMessageSound({ ...base, content: '`<@everyone>`' })).toBe(false);
});
});
@@ -1,3 +1,5 @@
import { mentionsEveryone } from '@backspace/shared/src/mentions';
/** /**
* Decides whether a freshly-arrived chat message should fire the in-app * Decides whether a freshly-arrived chat message should fire the in-app
* `message.ogg` cue. Pure, federation-aware (matches against any of the * `message.ogg` cue. Pure, federation-aware (matches against any of the
@@ -6,8 +8,8 @@
* Rule (Discord-default): * Rule (Discord-default):
* - Suppress messages authored by self (any id in myIds). * - Suppress messages authored by self (any id in myIds).
* - When allChannels=true, fire for every non-self message. * - When allChannels=true, fire for every non-self message.
* - Otherwise, fire only if the channel is a DM, or if the content contains * - Otherwise, fire only if the channel is a DM, if the content contains a
* a `<@${id}>` mention for any id in myIds. * `<@${id}>` mention for any id in myIds, or if it pings @everyone.
*/ */
export interface ShouldPlayMessageSoundInput { export interface ShouldPlayMessageSoundInput {
authorUserId: string; authorUserId: string;
@@ -22,6 +24,7 @@ export function shouldPlayMessageSound(input: ShouldPlayMessageSoundInput): bool
if (input.allChannels) return true; if (input.allChannels) return true;
if (input.isDmChannel) return true; if (input.isDmChannel) return true;
if (!input.content) return false; if (!input.content) return false;
if (mentionsEveryone(input.content)) return true;
for (const id of input.myIds) { for (const id of input.myIds) {
if (input.content.includes(`<@${id}>`)) return true; if (input.content.includes(`<@${id}>`)) return true;
} }