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:
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
},
|
||||
}, 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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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