feat: channel permissions UI, DM Sans font, private channel filtering, migration fix

- Rewrite ChannelSettingsModal with full tri-state permission override UI
  for roles and members (allow/neutral/deny per permission bit)
- Switch font from Inter to self-hosted DM Sans (woff2 variable fonts)
- Add client-side VIEW_CHANNEL filtering in ChannelSidebar for private channels
- Broadcast isPrivate flag on channel override changes
- Fix voice permission bit migration: gate behind persistent flag to prevent
  repeated re-runs that stripped STREAM from @everyone roles
- Add speakingUserIds set to voice store for efficient user-level lookups
- Clear current channel view when a channel is deleted
- Move .glass-strip to @layer utilities for proper CSS specificity
- Simplify avatar initials font size to proportional formula
This commit is contained in:
Jannis Braun
2026-03-13 02:47:32 +01:00
parent 2b810055f7
commit 07ef49eac0
15 changed files with 1053 additions and 177 deletions
+58 -69
View File
@@ -412,90 +412,79 @@ function migrateEveryoneRoles(db: Database.Database): void {
/**
* Remove the USE_VOICE_ACTIVITY bit (was bit 25) and shift STREAM (26→25)
* and DISCONNECT_MEMBERS (27→26) down. Idempotent: uses a sentinel flag in
* instance_settings metadata to avoid re-running.
* and DISCONNECT_MEMBERS (27→26) down.
*
* Gated behind a persistent `voice_bit_migrated` flag in instance_settings
* because the old and new bit positions overlap (STREAM moved into the same
* bit 25 that USE_VOICE_ACTIVITY occupied), making bit-inspection unreliable
* as an idempotency check. The previous version of this function had exactly
* that bug — it re-ran on every startup and silently stripped STREAM and
* DISCONNECT_MEMBERS from every role.
*
* On first run with the flag: repairs @everyone roles by re-adding STREAM,
* then sets the flag so it never runs again.
*/
function migrateRemoveVoiceActivityBit(db: Database.Database): void {
// Use a pragma-style check: if STREAM is already at bit 25 in DEFAULT_EVERYONE_PERMISSIONS
// of the @everyone roles, the migration has already run. But for robustness, use a flag column.
// We'll check if any role still has bit 25 set AND bit 26 set (old layout had both USE_VOICE_ACTIVITY
// and STREAM). Simplest approach: track via a one-time marker.
const OLD_VOICE_ACTIVITY = 1n << 25n; // old USE_VOICE_ACTIVITY
const OLD_STREAM = 1n << 26n; // old STREAM
const OLD_DISCONNECT = 1n << 27n; // old DISCONNECT_MEMBERS
// Check if any role still uses the old bit layout (has bit 26 or 27 set)
const roles = db.prepare('SELECT id, permissions FROM roles WHERE permissions IS NOT NULL').all() as { id: string; permissions: string }[];
const overrides = db.prepare('SELECT channel_id, target_type, target_id, allow, deny FROM channel_overrides').all() as {
channel_id: string; target_type: string; target_id: string; allow: string; deny: string;
}[];
let needsMigration = false;
for (const role of roles) {
try {
const p = BigInt(role.permissions);
if ((p & OLD_STREAM) !== 0n || (p & OLD_DISCONNECT) !== 0n || (p & OLD_VOICE_ACTIVITY) !== 0n) {
needsMigration = true;
break;
}
} catch { /* skip invalid */ }
}
if (!needsMigration) {
for (const ov of overrides) {
try {
const a = BigInt(ov.allow);
const d = BigInt(ov.deny);
if ((a & OLD_STREAM) !== 0n || (a & OLD_DISCONNECT) !== 0n || (a & OLD_VOICE_ACTIVITY) !== 0n ||
(d & OLD_STREAM) !== 0n || (d & OLD_DISCONNECT) !== 0n || (d & OLD_VOICE_ACTIVITY) !== 0n) {
needsMigration = true;
break;
}
} catch { /* skip invalid */ }
}
// Ensure the flag column exists
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
if (!cols.some(c => c.name === 'voice_bit_migrated')) {
db.exec('ALTER TABLE instance_settings ADD COLUMN voice_bit_migrated INTEGER DEFAULT 0');
}
if (!needsMigration) return;
// Check if already migrated
const row = db.prepare('SELECT voice_bit_migrated FROM instance_settings WHERE id = 1').get() as
{ voice_bit_migrated: number } | undefined;
if (row && row.voice_bit_migrated === 1) return;
function shiftPermBits(p: bigint): bigint {
const hasStream = (p & OLD_STREAM) !== 0n;
const hasDisconnect = (p & OLD_DISCONNECT) !== 0n;
// Clear bits 25, 26, 27
p = p & ~(OLD_VOICE_ACTIVITY | OLD_STREAM | OLD_DISCONNECT);
// Re-set at new positions
if (hasStream) p |= (1n << 25n); // STREAM now at 25
if (hasDisconnect) p |= (1n << 26n); // DISCONNECT_MEMBERS now at 26
return p;
}
console.log('Migrating: Shifting permission bits (removing USE_VOICE_ACTIVITY)...');
// The bit-shifting migration already ran (possibly many times) via the old
// broken code. All roles are already on the new layout (STREAM=25,
// DISCONNECT_MEMBERS=26). The damage is that repeated re-runs wiped those
// bits. Repair what we can:
const STREAM_BIT = 1n << 25n;
const updateRole = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?');
for (const role of roles) {
// Repair @everyone roles: re-add STREAM where it's missing.
// @everyone role id === space id, so join on that.
const spaces = db.prepare('SELECT id FROM spaces').all() as { id: string }[];
for (const space of spaces) {
const role = db.prepare('SELECT id, permissions FROM roles WHERE id = ?').get(space.id) as
{ id: string; permissions: string } | undefined;
if (!role?.permissions) continue;
try {
const old = BigInt(role.permissions);
const shifted = shiftPermBits(old);
if (shifted !== old) {
updateRole.run(shifted.toString(), role.id);
const perms = BigInt(role.permissions);
if ((perms & STREAM_BIT) === 0n) {
updateRole.run((perms | STREAM_BIT).toString(), role.id);
console.log(`Repair: Re-added STREAM to @everyone role for space ${space.id}`);
}
} catch { /* skip invalid */ }
}
const updateOverride = db.prepare(
'UPDATE channel_overrides SET allow = ?, deny = ? WHERE channel_id = ? AND target_type = ? AND target_id = ?'
// For non-@everyone roles, warn about potentially lost bits so admins can
// manually re-enable STREAM / DISCONNECT_MEMBERS if needed.
const customRoles = db.prepare(
'SELECT id, space_id, name, permissions FROM roles WHERE id NOT IN (SELECT id FROM spaces) AND permissions IS NOT NULL'
).all() as { id: string; space_id: string; name: string; permissions: string }[];
let warnCount = 0;
for (const role of customRoles) {
try {
const perms = BigInt(role.permissions);
if ((perms & STREAM_BIT) === 0n) {
warnCount++;
}
} catch { /* skip invalid */ }
}
if (warnCount > 0) {
console.log(
`Repair: ${warnCount} custom role(s) may be missing STREAM/DISCONNECT_MEMBERS permissions ` +
`due to a previous migration bug. Admins can re-enable these in Space Settings → Roles.`
);
for (const ov of overrides) {
try {
const oldAllow = BigInt(ov.allow);
const oldDeny = BigInt(ov.deny);
const newAllow = shiftPermBits(oldAllow);
const newDeny = shiftPermBits(oldDeny);
if (newAllow !== oldAllow || newDeny !== oldDeny) {
updateOverride.run(newAllow.toString(), newDeny.toString(), ov.channel_id, ov.target_type, ov.target_id);
}
} catch { /* skip invalid */ }
}
console.log('Migrating: Permission bit shift complete.');
// Set flag so this never runs again
db.prepare('UPDATE instance_settings SET voice_bit_migrated = 1 WHERE id = 1').run();
console.log('Migrating: Voice permission bit migration flagged as complete.');
}
/** Delete corrupted read_states rows where last_read_message_id is not a valid snowflake (numeric string) */
+39 -4
View File
@@ -38,6 +38,24 @@ function rowToCategory(row: typeof schema.channelCategories.$inferSelect): Chann
};
}
/**
* Check if a channel is private by looking for a VIEW_CHANNEL deny on @everyone.
* The @everyone role ID equals the space ID.
*/
function isChannelPrivate(channelId: string, spaceId: string): boolean {
const db = getDb();
const override = db.select().from(schema.channelOverrides).where(
and(
eq(schema.channelOverrides.channelId, channelId),
eq(schema.channelOverrides.targetType, 'role'),
eq(schema.channelOverrides.targetId, spaceId),
)
).get();
if (!override) return false;
const denyBits = BigInt(override.deny || '0');
return (denyBits & PermissionBits.VIEW_CHANNEL) !== 0n;
}
/**
* After a channel override changes, notify each space member:
* - VIEW_CHANNEL holders receive channel_updated (with their myPermissions)
@@ -49,6 +67,7 @@ function broadcastOverrideChange(spaceId: string, channelId: string): void {
if (!channel) return;
const channelData = rowToChannel(channel);
const priv = isChannelPrivate(channelId, spaceId);
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
if (!spaceIds.has(spaceId)) continue;
@@ -57,7 +76,7 @@ function broadcastOverrideChange(spaceId: string, channelId: string): void {
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
connectionManager.sendToUser(userId, {
type: 'channel_updated',
channel: { ...channelData, myPermissions: permissionsToString(perms) },
channel: { ...channelData, isPrivate: priv, myPermissions: permissionsToString(perms) },
spaceId,
});
} else {
@@ -184,7 +203,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
connectionManager.sendToUser(userId, {
type: 'channel_created',
channel: { ...channelData, myPermissions: permissionsToString(perms) },
channel: { ...channelData, isPrivate: false, myPermissions: permissionsToString(perms) },
spaceId: id,
});
}
@@ -400,13 +419,28 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
}
// Validate that allow/deny are valid bigint strings
let allowBits: bigint;
let denyBits: bigint;
try {
BigInt(allow || '0');
BigInt(deny || '0');
allowBits = BigInt(allow || '0');
denyBits = BigInt(deny || '0');
} catch {
return reply.code(400).send({ error: 'allow and deny must be valid decimal integer strings', statusCode: 400 });
}
// Privilege escalation guard: non-admin users can only grant permissions they possess
const callerPerms = computePermissions(request.userId, channel.spaceId);
if ((callerPerms & PermissionBits.ADMINISTRATOR) === 0n) {
const escalatedAllow = allowBits & ~callerPerms;
if (escalatedAllow !== 0n) {
return reply.code(403).send({ error: 'Cannot grant permissions you do not possess', statusCode: 403 });
}
const escalatedDeny = denyBits & ~callerPerms;
if (escalatedDeny !== 0n) {
return reply.code(403).send({ error: 'Cannot deny permissions you do not possess', statusCode: 403 });
}
}
// Upsert: delete existing then insert
db.transaction((tx) => {
tx.delete(schema.channelOverrides).where(
@@ -730,6 +764,7 @@ function broadcastChannelLayout(spaceId: string): void {
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
visibleChannels.push({
...rowToChannel(ch),
isPrivate: isChannelPrivate(ch.id, spaceId),
myPermissions: permissionsToString(perms),
});
}
+17 -1
View File
@@ -290,13 +290,29 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
// Compute space-level permissions for the requesting user
const spacePerms = computePermissions(request.userId, id);
// Batch-fetch all channel overrides for @everyone (role = spaceId) to determine isPrivate
const everyoneOverrides = db.select().from(schema.channelOverrides)
.where(and(
eq(schema.channelOverrides.targetType, 'role'),
eq(schema.channelOverrides.targetId, id),
))
.all();
const privateChannelIds = new Set<string>();
for (const o of everyoneOverrides) {
const denyBits = BigInt(o.deny || '0');
if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) {
privateChannelIds.add(o.channelId);
}
}
// Filter channels by VIEW_CHANNEL permission and attach per-channel myPermissions
const visibleChannels: (Channel & { myPermissions: string })[] = [];
const visibleChannels: (Channel & { isPrivate: boolean; myPermissions: string })[] = [];
for (const ch of channels) {
const perms = computePermissions(request.userId, id, ch.id);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
visibleChannels.push({
...rowToChannel(ch),
isPrivate: privateChannelIds.has(ch.id),
myPermissions: permissionsToString(perms),
});
}
+21 -1
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import { verifyJwt } from '../utils/auth.js';
import { getDb, schema } from '../db/index.js';
import { eq, inArray, desc, sql } from 'drizzle-orm';
import { eq, and, inArray, desc, sql } from 'drizzle-orm';
import { handleClientEvent } from './events.js';
import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
import type {
@@ -698,6 +698,25 @@ function buildReadyPayload(userId: string): {
arr.push(ch);
}
// Batch: determine which channels are private (VIEW_CHANNEL denied on @everyone)
// @everyone role ID equals the space ID, so we query for overrides targeting role = spaceId
const allEveroneOverrides = batchInArray(
spaceIds,
ids => db.select().from(schema.channelOverrides).where(
and(
eq(schema.channelOverrides.targetType, 'role'),
inArray(schema.channelOverrides.targetId, ids),
)
).all(),
);
const privateChannelIds = new Set<string>();
for (const o of allEveroneOverrides) {
const denyBits = BigInt(o.deny || '0');
if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) {
privateChannelIds.add(o.channelId);
}
}
// Batch: all categories for all spaces (1 query instead of N)
const allCategories = batchInArray(
spaceIds,
@@ -805,6 +824,7 @@ function buildReadyPayload(userId: string): {
topic: ch.topic,
position: ch.position ?? 0,
categoryId: ch.categoryId ?? null,
isPrivate: privateChannelIds.has(ch.id),
createdAt: ch.createdAt,
lastMessageId: lastMsgMap.get(ch.id) ?? null,
myPermissions: permissionsToString(chPerms),
+1
View File
@@ -149,6 +149,7 @@ export interface Channel {
topic: string | null;
position: number;
categoryId: string | null;
isPrivate?: boolean;
createdAt: number;
lastMessageId?: string | null;
myPermissions?: string; // Computed per-user BigInt decimal string
-3
View File
@@ -5,9 +5,6 @@
<meta name="color-scheme" content="dark" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Backspace</title>
</head>
<body class="bg-surface-base text-txt-primary">
Binary file not shown.
@@ -130,14 +130,20 @@ export function ChannelSidebar() {
});
}, [collapseKey]);
// Filter channels by VIEW_CHANNEL (defense-in-depth — server already filters,
// but this catches transient races where channels and permissions are briefly out of sync)
const visibleChannels = useMemo(() =>
channels.filter(ch => hasPermissionBit(channelPermissions.get(ch.id), PermissionBits.VIEW_CHANNEL)),
[channels, channelPermissions]);
// Group channels by category
const sortedCategories = useMemo(() =>
[...categories].sort((a, b) => a.position - b.position), [categories]);
const uncategorizedChannels = useMemo(() =>
channels.filter(c => !c.categoryId).sort((a, b) => a.position - b.position), [channels]);
visibleChannels.filter(c => !c.categoryId).sort((a, b) => a.position - b.position), [visibleChannels]);
const channelsByCategory = useMemo(() => {
const map = new Map<string, typeof channels>();
for (const ch of channels) {
for (const ch of visibleChannels) {
if (!ch.categoryId) continue;
let arr = map.get(ch.categoryId);
if (!arr) { arr = []; map.set(ch.categoryId, arr); }
@@ -147,7 +153,7 @@ export function ChannelSidebar() {
map.set(key, arr.sort((a, b) => a.position - b.position));
}
return map;
}, [channels]);
}, [visibleChannels]);
// Check if a collapsed category has unread channels
const categoryHasUnread = useCallback((categoryId: string) => {
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -58,8 +58,7 @@ function getDotMetrics(avatarSize: number, ringWidth: number = 0) {
export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId, ring, avatarColor }: AvatarProps) {
const openUserProfile = useUIStore((s) => s.openUserProfile);
const initials = name.charAt(0).toUpperCase();
// Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px
const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18;
const fontPx = Math.round(size * 0.4);
const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, avatarColor ?? user?.avatarColor);
const ringWidth = ring?.width ?? 0;
+5
View File
@@ -612,6 +612,11 @@ function handleEvent(origin: string, event: ServerEvent): void {
if (event.spaceId === curSpaceId3) {
setChannels3(curChannels3.filter(c => c.id !== event.channelId));
}
// If the user is currently viewing the deleted channel, clear it
const { currentChannelId: deletedViewChannelId } = useChatStore.getState();
if (deletedViewChannelId === event.channelId) {
useChatStore.getState().setCurrentChannel(null);
}
chPermsMap3.delete(event.channelId);
ctsMap3.delete(event.channelId);
coMap3.delete(event.channelId);
+15 -1
View File
@@ -21,6 +21,7 @@ interface VoiceState {
isScreenSharing: boolean;
participants: ParticipantInfo[];
speakingParticipantIds: Set<string>;
speakingUserIds: Set<string>;
connectionError: string | null;
isLiveKitConnected: boolean;
connectionQuality: 'excellent' | 'good' | 'poor' | 'lost' | 'unknown';
@@ -124,6 +125,7 @@ export const useVoiceStore = create<VoiceState>()(
isScreenSharing: false,
participants: [],
speakingParticipantIds: new Set(),
speakingUserIds: new Set(),
connectionError: null,
isLiveKitConnected: false,
connectionQuality: 'unknown',
@@ -264,7 +266,14 @@ export const useVoiceStore = create<VoiceState>()(
}),
setParticipants: (participants) => set({ participants }),
setSpeakingParticipants: (ids) => set({ speakingParticipantIds: ids }),
setSpeakingParticipants: (ids) => {
const userIds = new Set<string>();
for (const id of ids) {
const sep = id.indexOf(':');
if (sep !== -1) userIds.add(id.substring(0, sep));
}
set({ speakingParticipantIds: ids, speakingUserIds: userIds });
},
setConnectionError: (error) => set({ connectionError: error }),
setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }),
setConnectionQuality: (quality) => set({ connectionQuality: quality }),
@@ -378,6 +387,7 @@ export const useVoiceStore = create<VoiceState>()(
currentVoiceChannelId: null,
participants: [],
speakingParticipantIds: new Set(),
speakingUserIds: new Set(),
connectionError: null,
isLiveKitConnected: false,
connectionQuality: 'unknown',
@@ -437,6 +447,7 @@ export const useVoiceStore = create<VoiceState>()(
isScreenSharing: false,
participants: [],
speakingParticipantIds: new Set(),
speakingUserIds: new Set(),
connectionError: null,
isLiveKitConnected: false,
connectionQuality: 'unknown',
@@ -464,6 +475,7 @@ export const useVoiceStore = create<VoiceState>()(
isScreenSharing: false,
participants: [],
speakingParticipantIds: new Set(),
speakingUserIds: new Set(),
connectionError: null,
isLiveKitConnected: false,
connectionQuality: 'unknown',
@@ -488,6 +500,7 @@ export const useVoiceStore = create<VoiceState>()(
isScreenSharing: false,
participants: [],
speakingParticipantIds: new Set(),
speakingUserIds: new Set(),
connectionError: null,
isLiveKitConnected: false,
connectionQuality: 'unknown',
@@ -581,6 +594,7 @@ export const useVoiceStore = create<VoiceState>()(
merged.voiceUsers = currentState.voiceUsers;
merged.participants = currentState.participants;
merged.speakingParticipantIds = currentState.speakingParticipantIds;
merged.speakingUserIds = currentState.speakingUserIds;
merged.deafenedUserIds = currentState.deafenedUserIds;
merged.voiceUserStates = currentState.voiceUserStates;
merged.participantVolumes = currentState.participantVolumes;
+12 -12
View File
@@ -138,7 +138,7 @@
/* ── Glass Material Tiers ──
* .glass — Popovers, context menus, autocomplete (small, no backdrop)
* .glass-modal — Center-screen dialogs (large, with backdrop, higher opacity for legibility)
* .glass-strip — Space sidebar edge (no border-radius, directional shadow)
* .glass-strip — Space sidebar edge (moved to @layer utilities to win over bg-surface-* utilities)
* .glass-bubble — Persistent floating controls (voice bar, input pill)
* .glass-pill — Inline decorations (reactions, tags, lighter blur)
*
@@ -159,17 +159,6 @@
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
/* Space strip glass — no border-radius, rightward shadow */
.glass-strip {
backdrop-filter: blur(20px) saturate(120%);
-webkit-backdrop-filter: blur(20px) saturate(120%);
background: var(--glass-bg);
border-right: 1px solid rgba(255, 255, 255, 0.05);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
2px 0 12px rgba(0, 0, 0, 0.20);
}
/* Floating bubble glass — bottom bar, input pill */
.glass-bubble {
backdrop-filter: blur(20px) saturate(120%);
@@ -247,6 +236,17 @@
}
@layer utilities {
/* Space strip glass — in utilities layer so md:glass-strip wins over bg-surface-* */
.glass-strip {
backdrop-filter: blur(20px) saturate(120%);
-webkit-backdrop-filter: blur(20px) saturate(120%);
background: var(--glass-bg);
border-right: 1px solid rgba(255, 255, 255, 0.05);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
2px 0 12px rgba(0, 0, 0, 0.20);
}
.rounded-inherit {
border-radius: inherit;
}
+1 -1
View File
@@ -70,7 +70,7 @@ export default {
'glass': '0 2px 8px rgba(0,0,0,0.25), 0 8px 24px rgba(0,0,0,0.15), inset 0 1px 0 var(--glass-highlight)',
},
fontFamily: {
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'system-ui', 'sans-serif'],
sans: ['DM Sans', '-apple-system', 'BlinkMacSystemFont', 'system-ui', 'sans-serif'],
},
},
},