fix(voice): allow self-mute while push-to-talk is on (#3451)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-13 22:02:40 +02:00
1 parent 3657438467
commit f501d34179
27 files changed
+1025 -79

No files matched your search

@@ -206,6 +206,7 @@ const UserAreaInner = observer(
}, [hasVoiceConnection]);
const wrapperClassName = styles.userAreaInnerWrapper;
const isPushToTalkEffective = Keybind.isPushToTalkEffective();
const isSelfMuted = isMuted || muteReason === 'self';
const microphoneState = selectUserAreaMicrophoneState({
effectiveAudioMuted: isMuted,
effectiveAudioDeafened: isDeafened,
@@ -214,7 +215,6 @@ const UserAreaInner = observer(
isPermissionMuted,
muteReason,
isPushToTalkEffective,
isPushToTalkHeld: Keybind.pushToTalkHeld,
isPushToMuteEffective: Keybind.isPushToMuteEffective(),
isPushToMuteHeld: Keybind.pushToMuteHeld,
});
@@ -224,7 +224,8 @@ const UserAreaInner = observer(
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR);
if (isPushToTalkEffective) return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
if (isPushToTalkEffective && !isSelfMuted)
return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
})();
@@ -232,7 +233,8 @@ const UserAreaInner = observer(
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR);
if (isPushToTalkEffective) return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
if (isPushToTalkEffective && !isSelfMuted)
return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
})();
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
selectUserAreaMicrophoneState,
type UserAreaMicrophoneSignals,
} from '@app/features/app/components/layout/UserAreaState';
import {describe, expect, it} from 'vitest';
function signals(overrides: Partial<UserAreaMicrophoneSignals>): UserAreaMicrophoneSignals {
return {
effectiveAudioMuted: false,
effectiveAudioDeafened: false,
isGuildMuted: false,
isGuildDeafened: false,
isPermissionMuted: false,
muteReason: null,
isPushToTalkEffective: false,
isPushToMuteEffective: false,
isPushToMuteHeld: false,
...overrides,
};
}
describe('selectUserAreaMicrophoneState', () => {
it('shows the idle push to talk gate as unmuted without locking the toggle', () => {
expect(
selectUserAreaMicrophoneState(signals({isPushToTalkEffective: true, muteReason: 'voice_push_to_talk'})),
).toEqual({effectiveMuted: false, muteToggleLocked: false});
});
it('shows a self-muted push to talk user as muted', () => {
expect(
selectUserAreaMicrophoneState(
signals({isPushToTalkEffective: true, effectiveAudioMuted: true, muteReason: 'self'}),
),
).toEqual({effectiveMuted: true, muteToggleLocked: false});
});
it('shows a push to talk user muted by the failure latch as muted', () => {
expect(selectUserAreaMicrophoneState(signals({isPushToTalkEffective: true, muteReason: 'self'}))).toEqual({
effectiveMuted: true,
muteToggleLocked: false,
});
});
it('lets a self-deafened push to talk user click the toggle', () => {
expect(
selectUserAreaMicrophoneState(
signals({isPushToTalkEffective: true, effectiveAudioMuted: true, effectiveAudioDeafened: true}),
),
).toEqual({effectiveMuted: true, muteToggleLocked: false});
});
it('locks the toggle for server and permission mutes in push to talk', () => {
for (const overrides of [
{isGuildMuted: true, muteReason: 'guild' as const},
{isGuildDeafened: true},
{isPermissionMuted: true, muteReason: 'permission' as const},
]) {
expect(selectUserAreaMicrophoneState(signals({isPushToTalkEffective: true, ...overrides}))).toEqual({
effectiveMuted: true,
muteToggleLocked: true,
});
}
});
it('keeps voice activity and push to mute behaviour unchanged', () => {
expect(selectUserAreaMicrophoneState(signals({}))).toEqual({effectiveMuted: false, muteToggleLocked: false});
expect(selectUserAreaMicrophoneState(signals({effectiveAudioMuted: true, muteReason: 'self'}))).toEqual({
effectiveMuted: true,
muteToggleLocked: false,
});
expect(selectUserAreaMicrophoneState(signals({isPushToMuteEffective: true, isPushToMuteHeld: true}))).toEqual({
effectiveMuted: true,
muteToggleLocked: false,
});
expect(selectUserAreaMicrophoneState(signals({isGuildMuted: true}))).toEqual({
effectiveMuted: true,
muteToggleLocked: true,
});
});
});
@@ -10,7 +10,6 @@ export interface UserAreaMicrophoneSignals {
isPermissionMuted: boolean;
muteReason: UserAreaMuteReason;
isPushToTalkEffective: boolean;
isPushToTalkHeld: boolean;
isPushToMuteEffective: boolean;
isPushToMuteHeld: boolean;
}
@@ -32,13 +31,13 @@ export function selectUserAreaMicrophoneState(signals: UserAreaMicrophoneSignals
if (hardMuted) {
return {
effectiveMuted: true,
muteToggleLocked: muteToggleLocked || signals.isPushToTalkEffective,
muteToggleLocked,
};
}
if (signals.isPushToTalkEffective) {
return {
effectiveMuted: !signals.isPushToTalkHeld,
muteToggleLocked: true,
effectiveMuted: signals.effectiveAudioMuted || signals.muteReason === 'self',
muteToggleLocked,
};
}
return {
@@ -521,7 +521,7 @@ class KeybindManager {
}),
);
}
this.disposers.push(reactToPushToTalkModeChanges((options) => MediaEngine.handlePushToTalkModeChange(options)));
this.disposers.push(reactToPushToTalkModeChanges(() => MediaEngine.handlePushToTalkModeChange()));
this.disposers.push(
reaction(
() => ({entries: this.buildHoldBindingEntries(), routing: this.getGlobalHoldRouting()}),
@@ -229,7 +229,7 @@ describe('push-to-talk mode reconcile', () => {
dispose();
});
it('flips push-to-talk only on settled states and preserves self-mute when it does', () => {
it('flips push-to-talk only on settled states and reconciles once per flip', () => {
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
const reconcile = vi.fn();
const dispose = reactToPushToTalkModeChanges(reconcile);
@@ -238,7 +238,7 @@ describe('push-to-talk mode reconcile', () => {
emit(status);
expect(Keybind.isPushToTalkEffective()).toBe(effective);
expect(reconcile).toHaveBeenCalledTimes(1);
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: true});
expect(reconcile).toHaveBeenLastCalledWith();
};
expectFlip(portalStatus('declined'), false);
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual([]);
@@ -251,8 +251,12 @@ describe('push-to-talk mode reconcile', () => {
expectFlip(portalStatus('bound'), true);
reconcile.mockClear();
Keybind.setTransmitMode('voice_activity');
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: false});
expect(Keybind.isPushToTalkEffective()).toBe(false);
expect(reconcile).toHaveBeenCalledTimes(1);
expect(reconcile).toHaveBeenLastCalledWith();
Keybind.setTransmitMode('voice_push_to_talk');
expect(Keybind.isPushToTalkEffective()).toBe(true);
expect(reconcile).toHaveBeenCalledTimes(2);
dispose();
});
});
@@ -3,14 +3,13 @@
import Keybind from '@app/features/input/state/InputKeybind';
import {compareStructural, reaction} from 'mobx';
export function reactToPushToTalkModeChanges(onChange: (options: {preserveSelfMute: boolean}) => void): () => void {
export function reactToPushToTalkModeChanges(onChange: () => void): () => void {
return reaction(
() => ({
transmitMode: Keybind.transmitMode,
pushToTalk: Keybind.isPushToTalkEffective(),
pushToMute: Keybind.isPushToMuteEffective(),
}),
(current, previous) => onChange({preserveSelfMute: current.transmitMode === previous.transmitMode}),
() => onChange(),
{equals: compareStructural},
);
}