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
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:
@@ -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 (
|
||||
<span
|
||||
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 }}
|
||||
>
|
||||
@{displayName}
|
||||
|
||||
@@ -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<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 {
|
||||
filtered: MemberWithUser[];
|
||||
selectedIndex: number;
|
||||
selectedRef: React.RefObject<HTMLDivElement>;
|
||||
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 (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
|
||||
Members
|
||||
</div>
|
||||
{showEveryone && (
|
||||
<>
|
||||
<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) => (
|
||||
<MentionMemberRow
|
||||
key={member.userId}
|
||||
member={member}
|
||||
isSelected={i === selectedIndex}
|
||||
isSelected={i + memberOffset === selectedIndex}
|
||||
selectedRef={selectedRef}
|
||||
onSelect={onSelect}
|
||||
onSelect={(m) => onSelect({ kind: 'member', member: m })}
|
||||
roleColor={getMemberColor(member)}
|
||||
mobile={mobile}
|
||||
/>
|
||||
@@ -116,6 +189,7 @@ function DesktopMention({
|
||||
onSelect,
|
||||
anchorRef,
|
||||
getMemberColor,
|
||||
showEveryone,
|
||||
}: DesktopMentionProps) {
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
const floatingRef = useRef<HTMLDivElement>(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}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -158,6 +233,7 @@ function MobileMention({
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
getMemberColor,
|
||||
showEveryone,
|
||||
}: MobileMentionProps) {
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -193,6 +269,7 @@ function MobileMention({
|
||||
onSelect={onSelect}
|
||||
getMemberColor={getMemberColor}
|
||||
mobile={true}
|
||||
showEveryone={showEveryone}
|
||||
/>
|
||||
</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 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
<div
|
||||
|
||||
@@ -3,11 +3,12 @@ import { useT } from '../../i18n';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { MentionPopover } from './MentionPopover';
|
||||
import { MentionPopover, type MentionEntry } from './MentionPopover';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import { InputPopover, type InputPopoverTab } from './InputPopover';
|
||||
import { AttachmentProgress } from './AttachmentProgress';
|
||||
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 { useSettingsStore } from '../../stores/settingsStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
@@ -113,6 +114,9 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
const isDm = isDmChannel(channelId);
|
||||
const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES);
|
||||
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
|
||||
const stagedTransfers: Transfer[] = useMemo(() => {
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -668,6 +668,7 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'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<Dictionary> = {
|
||||
'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',
|
||||
};
|
||||
|
||||
@@ -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
|
||||
* `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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user