fix: persist server mute/deafen state across reloads and prevent client bypass
Server-side: add DB persistence for voice restrictions (schema, migration, ready payload, cleanup on leave). Client-side: fix four bugs that wiped or bypassed server restriction state — leaveVoice() no longer clears global restriction Sets, voice_state_update leave no longer drops amber icons, toggleMic/toggleDeafen now guard against server restrictions, and force-mute/ deafen uses direct setState instead of fragile toggle calls.
This commit is contained in:
@@ -142,6 +142,18 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Ensure voice_restrictions table exists (idempotent)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS voice_restrictions (
|
||||||
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
restriction_type TEXT NOT NULL,
|
||||||
|
moderator_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (space_id, user_id, restriction_type)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
// ─── RBAC Migration: Ensure @everyone roles exist for all spaces ─────────
|
// ─── RBAC Migration: Ensure @everyone roles exist for all spaces ─────────
|
||||||
migrateEveryoneRoles(db);
|
migrateEveryoneRoles(db);
|
||||||
|
|
||||||
|
|||||||
@@ -217,3 +217,13 @@ export const joinRequests = sqliteTable('join_requests', {
|
|||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
decidedAt: integer('decided_at'),
|
decidedAt: integer('decided_at'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const voiceRestrictions = sqliteTable('voice_restrictions', {
|
||||||
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
restrictionType: text('restriction_type').notNull(), // 'mute' | 'deafen'
|
||||||
|
moderatorId: text('moderator_id').notNull().references(() => users.id),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.spaceId, table.userId, table.restrictionType] }),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -768,6 +768,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
))
|
))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
// Clean up any voice restrictions for the removed member
|
||||||
|
db.delete(schema.voiceRestrictions).where(
|
||||||
|
and(
|
||||||
|
eq(schema.voiceRestrictions.spaceId, id),
|
||||||
|
eq(schema.voiceRestrictions.userId, uid),
|
||||||
|
)
|
||||||
|
).run();
|
||||||
|
|
||||||
// Broadcast member_left event
|
// Broadcast member_left event
|
||||||
connectionManager.sendToSpace(id, {
|
connectionManager.sendToSpace(id, {
|
||||||
type: 'member_left',
|
type: 'member_left',
|
||||||
@@ -1038,6 +1046,12 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
eq(schema.memberRoles.spaceId, id),
|
eq(schema.memberRoles.spaceId, id),
|
||||||
eq(schema.memberRoles.userId, targetId),
|
eq(schema.memberRoles.userId, targetId),
|
||||||
)).run();
|
)).run();
|
||||||
|
|
||||||
|
// Clean up any voice restrictions for the banned member
|
||||||
|
tx.delete(schema.voiceRestrictions).where(and(
|
||||||
|
eq(schema.voiceRestrictions.spaceId, id),
|
||||||
|
eq(schema.voiceRestrictions.userId, targetId),
|
||||||
|
)).run();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Broadcast member_left event so other clients update their member list
|
// Broadcast member_left event so other clients update their member list
|
||||||
|
|||||||
@@ -476,6 +476,36 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
|
|||||||
isScreenSharing: status.isScreenSharing,
|
isScreenSharing: status.isScreenSharing,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load persistent voice restrictions for this user in this space
|
||||||
|
const db = getDb();
|
||||||
|
const restrictions = db.select()
|
||||||
|
.from(schema.voiceRestrictions)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.voiceRestrictions.spaceId, spaceId),
|
||||||
|
eq(schema.voiceRestrictions.userId, userId),
|
||||||
|
))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const r of restrictions) {
|
||||||
|
if (r.restrictionType === 'mute') {
|
||||||
|
connectionManager.setServerMuted(userId, true);
|
||||||
|
connectionManager.sendToSpace(spaceId, {
|
||||||
|
type: 'voice_server_muted',
|
||||||
|
userId,
|
||||||
|
channelId,
|
||||||
|
muted: true,
|
||||||
|
});
|
||||||
|
} else if (r.restrictionType === 'deafen') {
|
||||||
|
connectionManager.setServerDeafened(userId, true);
|
||||||
|
connectionManager.sendToSpace(spaceId, {
|
||||||
|
type: 'voice_server_deafened',
|
||||||
|
userId,
|
||||||
|
channelId,
|
||||||
|
deafened: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVoiceLeave(userId: string): void {
|
function handleVoiceLeave(userId: string): void {
|
||||||
@@ -1092,6 +1122,26 @@ function handleVoiceServerMute(event: Record<string, unknown>, userId: string):
|
|||||||
|
|
||||||
connectionManager.setServerMuted(targetUserId, muted);
|
connectionManager.setServerMuted(targetUserId, muted);
|
||||||
|
|
||||||
|
// Persist to DB
|
||||||
|
const db = getDb();
|
||||||
|
if (muted) {
|
||||||
|
db.insert(schema.voiceRestrictions).values({
|
||||||
|
spaceId: meta.spaceId,
|
||||||
|
userId: targetUserId,
|
||||||
|
restrictionType: 'mute',
|
||||||
|
moderatorId: userId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).onConflictDoNothing().run();
|
||||||
|
} else {
|
||||||
|
db.delete(schema.voiceRestrictions).where(
|
||||||
|
and(
|
||||||
|
eq(schema.voiceRestrictions.spaceId, meta.spaceId),
|
||||||
|
eq(schema.voiceRestrictions.userId, targetUserId),
|
||||||
|
eq(schema.voiceRestrictions.restrictionType, 'mute'),
|
||||||
|
)
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
// Broadcast to all space members
|
// Broadcast to all space members
|
||||||
connectionManager.sendToSpace(meta.spaceId, {
|
connectionManager.sendToSpace(meta.spaceId, {
|
||||||
type: 'voice_server_muted',
|
type: 'voice_server_muted',
|
||||||
@@ -1129,6 +1179,26 @@ function handleVoiceServerDeafen(event: Record<string, unknown>, userId: string)
|
|||||||
|
|
||||||
connectionManager.setServerDeafened(targetUserId, deafened);
|
connectionManager.setServerDeafened(targetUserId, deafened);
|
||||||
|
|
||||||
|
// Persist to DB
|
||||||
|
const db = getDb();
|
||||||
|
if (deafened) {
|
||||||
|
db.insert(schema.voiceRestrictions).values({
|
||||||
|
spaceId: meta.spaceId,
|
||||||
|
userId: targetUserId,
|
||||||
|
restrictionType: 'deafen',
|
||||||
|
moderatorId: userId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).onConflictDoNothing().run();
|
||||||
|
} else {
|
||||||
|
db.delete(schema.voiceRestrictions).where(
|
||||||
|
and(
|
||||||
|
eq(schema.voiceRestrictions.spaceId, meta.spaceId),
|
||||||
|
eq(schema.voiceRestrictions.userId, targetUserId),
|
||||||
|
eq(schema.voiceRestrictions.restrictionType, 'deafen'),
|
||||||
|
)
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
connectionManager.sendToSpace(meta.spaceId, {
|
connectionManager.sendToSpace(meta.spaceId, {
|
||||||
type: 'voice_server_deafened',
|
type: 'voice_server_deafened',
|
||||||
userId: targetUserId,
|
userId: targetUserId,
|
||||||
|
|||||||
@@ -890,6 +890,19 @@ function buildReadyPayload(userId: string): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also include the connecting user's own DB-persisted restrictions
|
||||||
|
// (covers reconnect after disconnect timeout cleared in-memory state)
|
||||||
|
const myRestrictions = db.select()
|
||||||
|
.from(schema.voiceRestrictions)
|
||||||
|
.where(eq(schema.voiceRestrictions.userId, userId))
|
||||||
|
.all();
|
||||||
|
for (const r of myRestrictions) {
|
||||||
|
const existing = serverVoiceStates[userId] ?? { serverMuted: false, serverDeafened: false };
|
||||||
|
if (r.restrictionType === 'mute') existing.serverMuted = true;
|
||||||
|
if (r.restrictionType === 'deafen') existing.serverDeafened = true;
|
||||||
|
serverVoiceStates[userId] = existing;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch read states for unread tracking
|
// Fetch read states for unread tracking
|
||||||
const readStateRows = db.select()
|
const readStateRows = db.select()
|
||||||
.from(schema.readStates)
|
.from(schema.readStates)
|
||||||
|
|||||||
@@ -167,12 +167,25 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
setVoiceUserStatus(uid, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing);
|
setVoiceUserStatus(uid, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Populate server mute/deafen states
|
// Clear stale server voice states before applying fresh from ready payload
|
||||||
if (event.serverVoiceStates) {
|
{
|
||||||
const { setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState();
|
const { clearServerVoiceStates, setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState();
|
||||||
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean }>)) {
|
clearServerVoiceStates();
|
||||||
if (state.serverMuted) setServerMutedUser(uid, true);
|
if (event.serverVoiceStates) {
|
||||||
if (state.serverDeafened) setServerDeafenedUser(uid, true);
|
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean }>)) {
|
||||||
|
if (state.serverMuted) setServerMutedUser(uid, true);
|
||||||
|
if (state.serverDeafened) setServerDeafenedUser(uid, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Enforce local mute/deafen to match server restrictions (one-directional: only force-mute, never auto-unmute)
|
||||||
|
const myReadyId = useAuthStore.getState().user?.id;
|
||||||
|
if (myReadyId) {
|
||||||
|
const vs = useVoiceStore.getState();
|
||||||
|
if (vs.serverDeafenedUserIds.has(myReadyId) && !vs.isDeafened) {
|
||||||
|
useVoiceStore.setState({ isMuted: true, isDeafened: true });
|
||||||
|
} else if (vs.serverMutedUserIds.has(myReadyId) && !vs.isMuted) {
|
||||||
|
useVoiceStore.setState({ isMuted: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,10 +279,6 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
addVoiceUser(event.channelId, event.userId);
|
addVoiceUser(event.channelId, event.userId);
|
||||||
} else {
|
} else {
|
||||||
removeVoiceUser(event.channelId, event.userId);
|
removeVoiceUser(event.channelId, event.userId);
|
||||||
// Clear server mute/deafen state for departed user
|
|
||||||
const { setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState();
|
|
||||||
setServerMutedUser(event.userId, false);
|
|
||||||
setServerDeafenedUser(event.userId, false);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -280,14 +289,26 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
case 'voice_server_muted': {
|
case 'voice_server_muted': {
|
||||||
const { setServerMutedUser } = useVoiceStore.getState();
|
const { setServerMutedUser } = useVoiceStore.getState();
|
||||||
setServerMutedUser(event.userId, event.muted);
|
setServerMutedUser(event.userId, event.muted);
|
||||||
// If the local user was server-muted, force-mute the mic
|
|
||||||
const myUserId = useAuthStore.getState().user?.id;
|
const myUserId = useAuthStore.getState().user?.id;
|
||||||
if (event.userId === myUserId && event.muted) {
|
if (event.userId === myUserId) {
|
||||||
const vs = useVoiceStore.getState();
|
if (event.muted) {
|
||||||
if (!vs.isMuted) {
|
// Force-mute the mic
|
||||||
vs.toggleMic();
|
const vs = useVoiceStore.getState();
|
||||||
const voiceOrigin = vs.currentVoiceChannelId ? getChannelOrigin(vs.currentVoiceChannelId) : '';
|
if (!vs.isMuted) {
|
||||||
wsSend({ type: 'voice_status', isMuted: true, isDeafened: vs.isDeafened, isCameraOn: vs.isCameraOn, isScreenSharing: vs.isScreenSharing }, voiceOrigin);
|
useVoiceStore.setState({ isMuted: true });
|
||||||
|
const fresh = useVoiceStore.getState();
|
||||||
|
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
|
||||||
|
wsSend({ type: 'voice_status', isMuted: true, isDeafened: fresh.isDeafened, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Server unmuted — auto-restore mic unless still server-deafened
|
||||||
|
const vs = useVoiceStore.getState();
|
||||||
|
if (!vs.serverDeafenedUserIds.has(myUserId) && vs.isMuted) {
|
||||||
|
useVoiceStore.setState({ isMuted: false });
|
||||||
|
const fresh = useVoiceStore.getState();
|
||||||
|
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
|
||||||
|
wsSend({ type: 'voice_status', isMuted: false, isDeafened: fresh.isDeafened, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -296,15 +317,52 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
case 'voice_server_deafened': {
|
case 'voice_server_deafened': {
|
||||||
const { setServerDeafenedUser } = useVoiceStore.getState();
|
const { setServerDeafenedUser } = useVoiceStore.getState();
|
||||||
setServerDeafenedUser(event.userId, event.deafened);
|
setServerDeafenedUser(event.userId, event.deafened);
|
||||||
// If the local user was server-deafened, force-deafen (smart toggle sets both muted+deafened)
|
|
||||||
const myUid = useAuthStore.getState().user?.id;
|
const myUid = useAuthStore.getState().user?.id;
|
||||||
if (event.userId === myUid && event.deafened) {
|
if (event.userId === myUid) {
|
||||||
const vs = useVoiceStore.getState();
|
if (event.deafened) {
|
||||||
if (!vs.isDeafened) {
|
// Force-deafen (smart toggle sets both muted+deafened)
|
||||||
vs.toggleDeafen();
|
const vs = useVoiceStore.getState();
|
||||||
const fresh = useVoiceStore.getState();
|
if (!vs.isDeafened) {
|
||||||
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
|
useVoiceStore.setState({ isMuted: true, isDeafened: true });
|
||||||
wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin);
|
const fresh = useVoiceStore.getState();
|
||||||
|
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
|
||||||
|
wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin);
|
||||||
|
// Broadcast deafen to in-room participants via LiveKit data channel
|
||||||
|
import('./useLiveKit').then(({ getActiveRoom }) => {
|
||||||
|
const room = getActiveRoom();
|
||||||
|
if (room) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
room.localParticipant.publishData(
|
||||||
|
encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })),
|
||||||
|
{ reliable: true }
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Server un-deafened — auto-restore
|
||||||
|
const vs = useVoiceStore.getState();
|
||||||
|
if (vs.isDeafened) {
|
||||||
|
const stillServerMuted = vs.serverMutedUserIds.has(myUid);
|
||||||
|
useVoiceStore.setState({
|
||||||
|
isDeafened: false,
|
||||||
|
...(stillServerMuted ? {} : { isMuted: false }),
|
||||||
|
});
|
||||||
|
const fresh = useVoiceStore.getState();
|
||||||
|
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
|
||||||
|
wsSend({ type: 'voice_status', isMuted: fresh.isMuted, isDeafened: false, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin);
|
||||||
|
// Broadcast undeafen via LiveKit data channel
|
||||||
|
import('./useLiveKit').then(({ getActiveRoom }) => {
|
||||||
|
const room = getActiveRoom();
|
||||||
|
if (room) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
room.localParticipant.publishData(
|
||||||
|
encoder.encode(JSON.stringify({ type: 'deafen', deafened: false })),
|
||||||
|
{ reliable: true }
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
|||||||
import type { ParticipantInfo } from '../hooks/useLiveKit';
|
import type { ParticipantInfo } from '../hooks/useLiveKit';
|
||||||
import { AudioManager } from '../audio/AudioManager';
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from './spaceStore';
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from './spaceStore';
|
||||||
|
import { useAuthStore } from './authStore';
|
||||||
|
|
||||||
export interface ScreenShareConfig {
|
export interface ScreenShareConfig {
|
||||||
height: 1080 | 720 | 540;
|
height: 1080 | 720 | 540;
|
||||||
@@ -239,6 +240,11 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
|
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
|
||||||
|
|
||||||
toggleMic: () => set((state) => {
|
toggleMic: () => set((state) => {
|
||||||
|
// Server-muted/deafened users cannot unmute themselves
|
||||||
|
const myId = useAuthStore.getState().user?.id;
|
||||||
|
if (myId && state.isMuted && (state.serverMutedUserIds.has(myId) || state.serverDeafenedUserIds.has(myId))) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
if (state.isMuted && state.isDeafened) {
|
if (state.isMuted && state.isDeafened) {
|
||||||
// Unmuting while deafened → clear both (Discord behavior)
|
// Unmuting while deafened → clear both (Discord behavior)
|
||||||
return { isMuted: false, isDeafened: false };
|
return { isMuted: false, isDeafened: false };
|
||||||
@@ -246,6 +252,11 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
return { isMuted: !state.isMuted };
|
return { isMuted: !state.isMuted };
|
||||||
}),
|
}),
|
||||||
toggleDeafen: () => set((state) => {
|
toggleDeafen: () => set((state) => {
|
||||||
|
// Server-deafened users cannot undeafen themselves
|
||||||
|
const myId = useAuthStore.getState().user?.id;
|
||||||
|
if (myId && state.isDeafened && state.serverDeafenedUserIds.has(myId)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
if (state.isDeafened) {
|
if (state.isDeafened) {
|
||||||
// Undeafening → clear both
|
// Undeafening → clear both
|
||||||
return { isMuted: false, isDeafened: false };
|
return { isMuted: false, isDeafened: false };
|
||||||
@@ -358,8 +369,6 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
streamVolumes: new Map(),
|
streamVolumes: new Map(),
|
||||||
streamMutes: new Map(),
|
streamMutes: new Map(),
|
||||||
watchingStreams: new Set(),
|
watchingStreams: new Set(),
|
||||||
serverMutedUserIds: new Set(),
|
|
||||||
serverDeafenedUserIds: new Set(),
|
|
||||||
voiceUsers,
|
voiceUsers,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -397,7 +406,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'backspace-voice-settings',
|
name: 'backspace-voice-settings',
|
||||||
version: 6,
|
version: 7,
|
||||||
migrate: (persistedState: any, version: number) => {
|
migrate: (persistedState: any, version: number) => {
|
||||||
if (version === 0) {
|
if (version === 0) {
|
||||||
persistedState.streamAttenuationEnabled = false;
|
persistedState.streamAttenuationEnabled = false;
|
||||||
@@ -427,6 +436,9 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
persistedState.screenShareConfig.customBitrateKbps = null;
|
persistedState.screenShareConfig.customBitrateKbps = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (version < 7) {
|
||||||
|
// No data migration needed — Sets will be populated from server on next connect
|
||||||
|
}
|
||||||
return persistedState;
|
return persistedState;
|
||||||
},
|
},
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
@@ -447,7 +459,26 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
rnnoiseEnabled: state.rnnoiseEnabled,
|
rnnoiseEnabled: state.rnnoiseEnabled,
|
||||||
streamAttenuationEnabled: state.streamAttenuationEnabled,
|
streamAttenuationEnabled: state.streamAttenuationEnabled,
|
||||||
streamAttenuationStrength: state.streamAttenuationStrength,
|
streamAttenuationStrength: state.streamAttenuationStrength,
|
||||||
|
_serverMutedArr: [...state.serverMutedUserIds],
|
||||||
|
_serverDeafenedArr: [...state.serverDeafenedUserIds],
|
||||||
}),
|
}),
|
||||||
|
merge: (persistedState: any, currentState: VoiceState) => {
|
||||||
|
const merged = { ...currentState, ...persistedState };
|
||||||
|
// Reconstruct Sets from persisted arrays (Sets aren't JSON-serializable)
|
||||||
|
merged.serverMutedUserIds = new Set(persistedState?._serverMutedArr ?? []);
|
||||||
|
merged.serverDeafenedUserIds = new Set(persistedState?._serverDeafenedArr ?? []);
|
||||||
|
// Reconstruct non-persisted Sets/Maps to their defaults
|
||||||
|
merged.voiceUsers = currentState.voiceUsers;
|
||||||
|
merged.participants = currentState.participants;
|
||||||
|
merged.speakingParticipantIds = currentState.speakingParticipantIds;
|
||||||
|
merged.deafenedUserIds = currentState.deafenedUserIds;
|
||||||
|
merged.voiceUserStates = currentState.voiceUserStates;
|
||||||
|
merged.participantVolumes = currentState.participantVolumes;
|
||||||
|
merged.streamVolumes = currentState.streamVolumes;
|
||||||
|
merged.streamMutes = currentState.streamMutes;
|
||||||
|
merged.watchingStreams = currentState.watchingStreams;
|
||||||
|
return merged;
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user