diff --git a/docs/roadmap-resenha.md b/docs/roadmap-resenha.md index 9c579ac1..0bab4d1f 100644 --- a/docs/roadmap-resenha.md +++ b/docs/roadmap-resenha.md @@ -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 | | 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 | +| 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 | | 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 | diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 1e0dc932..70a0f1ea 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import { config } from '../config.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 { createSnapshot } from '../utils/backup.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 // lost before the tombstone fix). Idempotent — safe no-op on every later boot. backfillOneOnOneDmMembership(sqlite); + grantMentionEveryoneToLegacyRoles(sqlite); // 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; diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 2b122b33..d939f6a1 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -87,3 +87,44 @@ export function backfillOneOnOneDmMembership(db: Database.Database): void { run(); 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)`); + } +} diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index b8338a46..660447f6 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -16,6 +16,7 @@ import { } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.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'; /** @@ -276,7 +277,8 @@ export async function messageRoutes(app: FastifyInstance): Promise { }, }, async (request, reply) => { 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); if (!spaceId) { @@ -295,6 +297,18 @@ export async function messageRoutes(app: FastifyInstance): Promise { const db0 = getDb(); 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 hasAttachments = attachmentIds && attachmentIds.length > 0; @@ -423,9 +437,21 @@ export async function messageRoutes(app: FastifyInstance): Promise { 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(); db.update(schema.messages) - .set({ content: content.trim(), editedAt: now }) + .set({ content: edited.trim(), editedAt: now }) .where(eq(schema.messages.id, id)) .run(); diff --git a/packages/shared/package.json b/packages/shared/package.json index 8d1db21f..7256b63b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -16,7 +16,9 @@ "./src/constants": "./src/constants.ts", "./src/constants.js": "./src/constants.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": { "build": "tsc", diff --git a/packages/shared/src/mentions.ts b/packages/shared/src/mentions.ts new file mode 100644 index 00000000..7fcdb90e --- /dev/null +++ b/packages/shared/src/mentions.ts @@ -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', + ); +} diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index 88deea84..1fc25641 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -16,6 +16,7 @@ export const PermissionBits = { ATTACH_FILES: 1n << 12n, READ_MESSAGE_HISTORY: 1n << 13n, ADD_REACTIONS: 1n << 14n, + MENTION_EVERYONE: 1n << 15n, CONNECT: 1n << 20n, SPEAK: 1n << 21n, MUTE_MEMBERS: 1n << 22n, @@ -38,6 +39,10 @@ export const DEFAULT_EVERYONE_PERMISSIONS = PermissionBits.ATTACH_FILES | PermissionBits.READ_MESSAGE_HISTORY | 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; // ─── Helpers ──────────────────────────────────────────────────────────────── diff --git a/packages/web/src/components/chat/MentionBadge.tsx b/packages/web/src/components/chat/MentionBadge.tsx index 70ec259f..8aa327b6 100644 --- a/packages/web/src/components/chat/MentionBadge.tsx +++ b/packages/web/src/components/chat/MentionBadge.tsx @@ -3,17 +3,21 @@ import type { User } from '@backspace/shared'; import { useSpaceStore } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; import { useCanonicalUserView } from '../../utils/userViewLookup'; +import { EVERYONE_MENTION_ID } from '@backspace/shared/src/mentions'; +import { useT } from '../../i18n'; interface MentionBadgeProps { userId: string; } export const MentionBadge = React.memo(function MentionBadge({ userId }: MentionBadgeProps) { + const t = useT(); const members = useSpaceStore((s) => s.members); const spaces = useSpaceStore((s) => s.spaces); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const openUserProfile = useUIStore((s) => s.openUserProfile); + const isEveryone = userId === EVERYONE_MENTION_ID; const member = members.find((m) => m.userId === userId); const space = spaces.find((s) => s.id === currentSpaceId); const ownerId = space?.ownerId; @@ -25,7 +29,12 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention let displayName: 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; if (member.roles && member.roles.length > 0) { 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 } } else { - displayName = 'Unknown User'; + displayName = t('mention.unknownUser'); color = '#a0a0aa'; // text-secondary fallback } const handleClick = (e: React.MouseEvent) => { - if (!member || !memberUser) return; + if (isEveryone || !member || !memberUser) return; e.stopPropagation(); openUserProfile(memberUser, e.currentTarget.getBoundingClientRect()); }; @@ -52,7 +61,9 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention return ( @{displayName} diff --git a/packages/web/src/components/chat/MentionPopover.tsx b/packages/web/src/components/chat/MentionPopover.tsx index 37b07beb..85170410 100644 --- a/packages/web/src/components/chat/MentionPopover.tsx +++ b/packages/web/src/components/chat/MentionPopover.tsx @@ -6,6 +6,16 @@ import { useSpaceStore } from '../../stores/spaceStore'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; import { useCanonicalUserView } from '../../utils/userViewLookup'; 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; @@ -64,17 +74,60 @@ function MentionMemberRow({ interface MentionPopoverProps { query: string; selectedIndex: number; - onSelect: (member: MemberWithUser) => void; + onSelect: (entry: MentionEntry) => void; anchorRef: React.RefObject; + /** Whether the @everyone row is offered — mirrors MENTION_EVERYONE. */ + showEveryone: boolean; +} + +function MentionEveryoneRow({ + isSelected, + selectedRef, + onSelect, + mobile, +}: { + isSelected: boolean; + selectedRef: React.RefObject; + 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 ( +
onSelect({ kind: 'everyone' })} + className={`flex items-center mx-1 rounded cursor-pointer transition-colors ${rowSizing} ${ + isSelected ? 'bg-interactive-selected' : 'hover:bg-interactive-hover' + }`} + > +
+ @ +
+ + {t('mention.everyone.label')} + + + {t('mention.everyone.description')} + +
+ ); } interface ResolvedListProps { filtered: MemberWithUser[]; selectedIndex: number; selectedRef: React.RefObject; - onSelect: (member: MemberWithUser) => void; + onSelect: (entry: MentionEntry) => void; getMemberColor: (member: MemberWithUser) => string | undefined; mobile: boolean; + showEveryone: boolean; } function MemberList({ @@ -84,19 +137,39 @@ function MemberList({ onSelect, getMemberColor, mobile, + showEveryone, }: 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 ( <> -
- Members -
+ {showEveryone && ( + <> +
+ {t('mention.everyone.section')} +
+ + + )} + {filtered.length > 0 && ( +
+ {t('mention.members')} +
+ )} {filtered.map((member, i) => ( onSelect({ kind: 'member', member: m })} roleColor={getMemberColor(member)} mobile={mobile} /> @@ -116,6 +189,7 @@ function DesktopMention({ onSelect, anchorRef, getMemberColor, + showEveryone, }: DesktopMentionProps) { const selectedRef = useRef(null); const floatingRef = useRef(null); @@ -123,7 +197,7 @@ function DesktopMention({ const { style } = useFloatingPosition(anchorRef, floatingRef, { placement: 'top', offset: 4, - enabled: filtered.length > 0, + enabled: filtered.length > 0 || showEveryone, }); // Scroll selected item into view @@ -141,6 +215,7 @@ function DesktopMention({ onSelect={onSelect} getMemberColor={getMemberColor} mobile={false} + showEveryone={showEveryone} /> , @@ -158,6 +233,7 @@ function MobileMention({ selectedIndex, onSelect, getMemberColor, + showEveryone, }: MobileMentionProps) { const selectedRef = useRef(null); @@ -193,6 +269,7 @@ function MobileMention({ onSelect={onSelect} getMemberColor={getMemberColor} mobile={true} + showEveryone={showEveryone} /> @@ -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 spaces = useSpaceStore((s) => s.spaces); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); @@ -220,7 +297,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me .slice(0, MAX_RESULTS); }, [members, query]); - if (filtered.length === 0) return null; + if (filtered.length === 0 && !showEveryone) return null; const getMemberColor = (member: MemberWithUser): string | undefined => { if (member.roles && member.roles.length > 0) { @@ -240,6 +317,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me anchorRef={anchorRef} filtered={filtered} getMemberColor={getMemberColor} + showEveryone={showEveryone} /> ); } @@ -252,6 +330,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me anchorRef={anchorRef} filtered={filtered} getMemberColor={getMemberColor} + showEveryone={showEveryone} /> ); } diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index 6d1a0ab2..fedb1c96 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -31,6 +31,7 @@ import { type PendingAttachmentView, } from '../../stores/pendingMessageStore'; import { useTransferStore } from '../../stores/transferStore'; +import { mentionsEveryone } from '@backspace/shared/src/mentions'; interface MessageProps { message: MessageWithUser | PendingMessageView; @@ -419,8 +420,12 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId const replyRoleColor = (msg: { userId: string }) => getMemberDisplayColor(msg.userId); - // Self-mention highlighting - const isMentioned = currentUser && message.content?.includes('<@' + currentUser.id + '>'); + // Mention highlighting — the same amber row for a direct ping and for + // @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 = (
{ @@ -207,6 +211,17 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp .slice(0, 8); }, [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(() => { if (typingTimeoutRef.current) return; const dm = isDmChannel(channelId); @@ -386,13 +401,14 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp }; const selectMention = useCallback( - (member: MemberWithUser) => { + (entry: MentionEntry) => { if (!mentionState) return; const textarea = textareaRef.current; const cursorPos = textarea?.selectionStart ?? draftText.length; const before = draftText.slice(0, mentionState.startIndex); 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; setDraft(channelId, newContent); setMentionState(null); @@ -412,11 +428,11 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp const handleKeyDown = (e: React.KeyboardEvent): void => { // Mention popover keyboard navigation - if (mentionState && filteredMembers.length > 0) { + if (mentionState && mentionRowCount > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); 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; } @@ -429,8 +445,12 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); - const selected = filteredMembers[mentionState.selectedIndex]; - if (selected) selectMention(selected); + if (showEveryone && mentionState.selectedIndex === 0) { + selectMention({ kind: 'everyone' }); + return; + } + const selected = filteredMembers[mentionState.selectedIndex - (showEveryone ? 1 : 0)]; + if (selected) selectMention({ kind: 'member', member: selected }); return; } if (e.key === 'Escape') { @@ -822,6 +842,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp selectedIndex={mentionState.selectedIndex} onSelect={selectMention} anchorRef={inputContainerRef} + showEveryone={showEveryone} /> )} diff --git a/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx index 052060a2..f1c3dc07 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx @@ -37,6 +37,7 @@ const PERMISSION_GROUPS: { nameKey: TranslationKey; perms: PermDef[] }[] = [ { bit: PermissionBits.ATTACH_FILES, labelKey: 'perm.attachFiles' }, { bit: PermissionBits.READ_MESSAGE_HISTORY, labelKey: 'perm.readHistory' }, { bit: PermissionBits.ADD_REACTIONS, labelKey: 'perm.addReactions' }, + { bit: PermissionBits.MENTION_EVERYONE, labelKey: 'perm.mentionEveryone' }, ], }, { diff --git a/packages/web/src/i18n/locales/en.ts b/packages/web/src/i18n/locales/en.ts index e20293fc..fdd4413b 100644 --- a/packages/web/src/i18n/locales/en.ts +++ b/packages/web/src/i18n/locales/en.ts @@ -669,6 +669,7 @@ export const en = { 'perm.manageMessages': 'Manage Messages', 'perm.attachFiles': 'Attach Files', 'perm.readHistory': 'Read Message History', + 'perm.mentionEveryone': 'Mention @everyone', 'perm.addReactions': 'Add Reactions', 'perm.connect': 'Connect', 'perm.speak': 'Speak', @@ -1019,6 +1020,12 @@ export const en = { 'mobile.voice.shareScreen': 'Share screen', 'mobile.voice.stopSharing': 'Stop sharing screen', '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; export type TranslationKey = keyof typeof en; diff --git a/packages/web/src/i18n/locales/pt-BR.ts b/packages/web/src/i18n/locales/pt-BR.ts index 97e9b40b..0d565bf3 100644 --- a/packages/web/src/i18n/locales/pt-BR.ts +++ b/packages/web/src/i18n/locales/pt-BR.ts @@ -668,6 +668,7 @@ export const ptBR: Partial = { 'perm.manageMessages': 'Gerenciar mensagens', 'perm.attachFiles': 'Anexar arquivos', 'perm.readHistory': 'Ler histórico de mensagens', + 'perm.mentionEveryone': 'Mencionar @everyone', 'perm.addReactions': 'Adicionar reações', 'perm.connect': 'Conectar', 'perm.speak': 'Falar', @@ -1018,4 +1019,10 @@ export const ptBR: Partial = { 'mobile.voice.shareScreen': 'Compartilhar a tela', 'mobile.voice.stopSharing': 'Parar de compartilhar', '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', }; diff --git a/packages/web/src/utils/everyoneMention.test.ts b/packages/web/src/utils/everyoneMention.test.ts new file mode 100644 index 00000000..108b2f05 --- /dev/null +++ b/packages/web/src/utils/everyoneMention.test.ts @@ -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); + }); +}); diff --git a/packages/web/src/utils/notificationFilters.ts b/packages/web/src/utils/notificationFilters.ts index 7fd22697..1ea4330a 100644 --- a/packages/web/src/utils/notificationFilters.ts +++ b/packages/web/src/utils/notificationFilters.ts @@ -1,3 +1,5 @@ +import { mentionsEveryone } from '@backspace/shared/src/mentions'; + /** * Decides whether a freshly-arrived chat message should fire the in-app * `message.ogg` cue. Pure, federation-aware (matches against any of the @@ -6,8 +8,8 @@ * Rule (Discord-default): * - Suppress messages authored by self (any id in myIds). * - When allChannels=true, fire for every non-self message. - * - Otherwise, fire only if the channel is a DM, or if the content contains - * a `<@${id}>` mention for any id in myIds. + * - Otherwise, fire only if the channel is a DM, if the content contains a + * `<@${id}>` mention for any id in myIds, or if it pings @everyone. */ export interface ShouldPlayMessageSoundInput { authorUserId: string; @@ -22,6 +24,7 @@ export function shouldPlayMessageSound(input: ShouldPlayMessageSoundInput): bool if (input.allChannels) return true; if (input.isDmChannel) return true; if (!input.content) return false; + if (mentionsEveryone(input.content)) return true; for (const id of input.myIds) { if (input.content.includes(`<@${id}>`)) return true; }