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
@@ -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;