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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user