feat(voice): open voice popouts in the browser (#2400)

This commit is contained in:
Hampus
2026-09-02 17:37:28 +02:00
committed by GitHub
parent adb9a689f0
commit 7bd021cd5c
43 changed files with 512 additions and 138 deletions
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# مجتمع مشترك} other {# مجتمعات م
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# آخر} other {# آخرين}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# مشارك في المكالمة} other {# مشاركين في المكالمة}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# обща общност} other {# общи общ
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# друг} other {# други}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# участник в разговора} other {# участници в разговора}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# společná komunita} other {# společné komunity
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# další} few {# další} many {# dalších} other {# dalších}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# účastník hovoru} few {# účastníci hovoru} many {# účastníka hovoru} other {# účastníků hovoru}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# fælles fællesskab} other {# fælles fællesskab
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# anden} other {# andre}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# deltager i opkald} other {# deltagere i opkald}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# gemeinsame Community} other {# gemeinsame Communi
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# andere} other {# andere}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# Teilnehmer im Anruf} other {#Teilnehmer im Anruf}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# κοινή κοινότητα} other {# κοιν
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# άλλος} other {# άλλοι}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# συμμετέχων στην κλήση} other {# συμμετέχοντες στην κλήση}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# mutual community} other {# mutual communities}}"
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# other} other {# others}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# participant in call} other {# participants in call}}"
@@ -405,7 +405,7 @@ msgstr "{count, plural, one {# mutual community} other {# mutual communities}}"
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# other} other {# others}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# participant in call} other {# participants in call}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# comunidad en común} other {# comunidades en com
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# más} other {# más}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# participante en la llamada} other {# participantes en la llamada}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# comunidad en común} other {# comunidades en com
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# más} other {# más}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# participante en la llamada} other {# participantes en la llamada}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# yhteinen yhteisö} other {# yhteistä yhteisöä}
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# muu} other {# muuta}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# osallistuja puhelussa} other {# osallistujaa puhelussa}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# communauté en commun} other {# communautés en c
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# autre} other {# autres}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# participant à l'appel} other {# participants à l'appel}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# קהילה משותפת} other {# קהילות מ
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# אחר} other {# אחרים}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# משתתף בשיחה} other {# משתתפים בשיחה}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# साझा समुदाय} other {# सा
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# अन्य} other {# अन्य}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# प्रतिभागी कॉल में है} other {# प्रतिभागी कॉल में हैं}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# zajednička zajednica} other {# zajedničke zajed
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# drugi} other {# ostalih}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# sudionik u pozivu} few {# sudionika u pozivu} other {# sudionika u pozivu}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# közös közösség} other {# közös közösség
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# másik} other {# többiek}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# résztvevő a hívásban} other {# résztvevő a hívásban}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# komunitas bersama} other {# komunitas bersama}}"
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# lainnya} other {# lainnya}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# peserta dalam panggilan} other {# peserta dalam panggilan}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# community in comune} other {# community in comune
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# altro} other {# altri}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# partecipante alla chiamata} other {# partecipanti alla chiamata}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {#つの共通コミュニティ} other {#つの共
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {#人} other {#人}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {#人が通話中} other {#人が通話中}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {#개의 공통 커뮤니티} other {#개의 공통
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {#명 외} other {#명 외}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {#명 통화 중} other {#명 통화 중}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# bendra bendruomenė} other {# bendros bendruomen
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# kitas} other {# kiti}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# dalyvis skambutyje} other {# dalyviai skambutyje}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# gemeenschappelijke community} other {# gemeenscha
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# ander} other {# anderen}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# deelnemer aan gesprek} other {# deelnemers aan gesprek}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# felles fellesskap} other {# felles fellesskap}}"
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# annen} other {# andre}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# deltaker i samtalen} other {# deltakere i samtalen}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# wspólna społeczność} few {# wspólne społecz
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# inna osoba} few {# inne osoby} many {# innych osób} other {# innych osób}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# uczestnik w rozmowie} few {# uczestników w rozmowie} many {# uczestników w rozmowie} other {# uczestnika w rozmowie}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# comunidade em comum} other {# comunidades em comu
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# outro} other {# outros}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# participante na chamada} other {# participantes na chamada}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# comunitate comună} other {# comunități comune}
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# altul} other {# alții}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# participant în apel} other {# participanți în apel}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# общее сообщество} few {# общи
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# другой} other {# других}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# участник звонка} few {# участника звонка} many {# участников звонка} other {# участника звонка}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# gemensam community} other {# gemensamma communiti
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# annan} other {# andra}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# deltagare i samtalet} other {# deltagare i samtalet}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# ชุมชนร่วมกัน} other {#
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# คนอื่น} other {# คนอื่น}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# คนในสาย} other {# คนในสาย}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# ortak topluluk} other {# ortak topluluk}}"
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# diğer} other {# diğerleri}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# katılımcı aramada} other {# katılımcı aramada}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# спільна спільнота} few {# спі
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# інший} few {# інших} many {# інших} other {# інших}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# учасник у дзвінку} few {# учасники у дзвінку} many {# учасників у дзвінку} other {# учасника у дзвінку}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# cộng đồng chung} other {# cộng đồng chu
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# người khác} other {# người khác}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# người tham gia cuộc gọi} other {# người tham gia cuộc gọi}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {#个共同社区} other {#个共同社区}}"
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# 位其他用户} other {# 位其他用户}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# 位参与者正在通话中} other {# 位参与者正在通话中}}"
@@ -404,7 +404,7 @@ msgstr "{count, plural, one {# 個共同社群} other {# 個共同社群}}"
msgid "{count, plural, one {# other} other {# others}}"
msgstr "{count, plural, one {# 位} other {# 位}}"
#: src/features/voice/components/VoiceCallView.tsx:497
#: src/features/voice/components/VoiceCallView.tsx:495
msgid "{count, plural, one {# participant in call} other {# participants in call}}"
msgstr "{count, plural, one {# 位參與者在通話中} other {# 位參與者在通話中}}"
@@ -382,16 +382,14 @@ const VoiceCallViewInner = observer(
void toggleVoiceCallAppFullscreen();
}, [toggleVoiceCallAppFullscreen]);
const handlePopOutCall = useCallback(() => {
void (async () => {
if (isVoiceCallAppFullscreen) {
await exitVoiceCallAppFullscreen();
}
PopoutWindowManager.openCallPopout({
channelId: channel.id,
guildId: channel.guildId ?? null,
title: channel.name ?? i18n._(VOICE_CALL_DESCRIPTOR),
});
})();
const didOpen = PopoutWindowManager.openCallPopout({
channelId: channel.id,
guildId: channel.guildId ?? null,
title: channel.name ?? i18n._(VOICE_CALL_DESCRIPTOR),
});
if (didOpen && isVoiceCallAppFullscreen) {
void exitVoiceCallAppFullscreen();
}
}, [channel.guildId, channel.id, channel.name, exitVoiceCallAppFullscreen, i18n, isVoiceCallAppFullscreen]);
const fullscreenButtonLabel = isVoiceCallAppFullscreen
? i18n._(EXIT_FULLSCREEN_DESCRIPTOR)
@@ -215,16 +215,14 @@ export const CompactVoiceCallViewInner: React.FC<CompactVoiceCallViewProps> = ob
return BoldIcon;
}, [isVoiceCallAppFullscreen]);
const handlePopOutCall = useCallback(() => {
void (async () => {
if (isVoiceCallAppFullscreen) {
await exitVoiceCallAppFullscreen();
}
PopoutWindowManager.openCallPopout({
channelId: channel.id,
guildId: channel.guildId ?? null,
title: channel.name ?? i18n._(VOICE_CALL_TITLE_DESCRIPTOR),
});
})();
const didOpen = PopoutWindowManager.openCallPopout({
channelId: channel.id,
guildId: channel.guildId ?? null,
title: channel.name ?? i18n._(VOICE_CALL_TITLE_DESCRIPTOR),
});
if (didOpen && isVoiceCallAppFullscreen) {
void exitVoiceCallAppFullscreen();
}
}, [channel.guildId, channel.id, channel.name, exitVoiceCallAppFullscreen, i18n, isVoiceCallAppFullscreen]);
useEffect(() => {
if (fullscreenRequestNonce == null) return;
@@ -34,7 +34,7 @@ interface PopoutTitlebarProps {
title: string;
showTitle?: boolean;
isAlwaysOnTop: boolean;
onToggleAlwaysOnTop: () => void;
onToggleAlwaysOnTop?: () => void;
onRestore: () => void;
onClose: () => void;
}
@@ -66,19 +66,21 @@ export const PopoutTitlebar: React.FC<PopoutTitlebarProps> = ({
</span>
)}
<div role="group" className={styles.actions} data-flx="voice.popout-titlebar.actions">
<FocusRing offset={-2} data-flx="voice.popout-titlebar.focus-ring.pin">
<button
type="button"
className={clsx(nativeTitlebarStyles.controlButton, isAlwaysOnTop && styles.actionButtonActive)}
onClick={onToggleAlwaysOnTop}
aria-pressed={isAlwaysOnTop}
aria-label={pinLabel}
title={pinLabel}
data-flx="voice.popout-titlebar.control-button.toggle-always-on-top"
>
<PinIcon weight="bold" data-flx="voice.popout-titlebar.pin-icon" />
</button>
</FocusRing>
{onToggleAlwaysOnTop && (
<FocusRing offset={-2} data-flx="voice.popout-titlebar.focus-ring.pin">
<button
type="button"
className={clsx(nativeTitlebarStyles.controlButton, isAlwaysOnTop && styles.actionButtonActive)}
onClick={onToggleAlwaysOnTop}
aria-pressed={isAlwaysOnTop}
aria-label={pinLabel}
title={pinLabel}
data-flx="voice.popout-titlebar.control-button.toggle-always-on-top"
>
<PinIcon weight="bold" data-flx="voice.popout-titlebar.pin-icon" />
</button>
</FocusRing>
)}
<FocusRing offset={-2} data-flx="voice.popout-titlebar.focus-ring.restore">
<button
type="button"
@@ -26,21 +26,23 @@ const logger = new Logger('PopoutWindow');
interface PopoutWindowProps {
windowKey: string;
windowGeneration: number;
title: string;
showTitlebarTitle?: boolean;
width: number;
height: number;
isAlwaysOnTop: boolean;
onToggleAlwaysOnTop: () => void;
onToggleAlwaysOnTop?: () => void;
onRestore: () => void;
onClosed: () => void;
onWindowOpened?: (childWindow: Window) => void;
onClosed: (windowGeneration: number) => void;
onWindowOpened?: (childWindow: Window, windowGeneration: number) => void;
children: React.ReactNode;
}
interface PopoutChildState {
childWindow: Window;
container: HTMLElement;
windowGeneration: number;
}
interface PopoutWindowOverlayTreeProps {
@@ -70,6 +72,7 @@ function prepareChildDocument(childWindow: Window, title: string): HTMLElement {
export const PopoutWindow: React.FC<PopoutWindowProps> = ({
windowKey,
windowGeneration,
title,
showTitlebarTitle = true,
width,
@@ -93,7 +96,7 @@ export const PopoutWindow: React.FC<PopoutWindowProps> = ({
const childWindow = window.open('about:blank', windowKey, features);
if (!childWindow) {
logger.warn('Failed to open popout window', {windowKey});
onClosedRef.current();
onClosedRef.current(windowGeneration);
return;
}
const container = prepareChildDocument(childWindow, initialTitleRef.current);
@@ -103,12 +106,12 @@ export const PopoutWindow: React.FC<PopoutWindowProps> = ({
const handlePageHide = (): void => {
if (closed) return;
closed = true;
onClosedRef.current();
onClosedRef.current(windowGeneration);
};
childWindow.addEventListener('pagehide', handlePageHide);
childWindow.focus();
onWindowOpenedRef.current?.(childWindow);
setChildState({childWindow, container});
onWindowOpenedRef.current?.(childWindow, windowGeneration);
setChildState({childWindow, container, windowGeneration});
return () => {
disconnectThemeObserver();
disconnectStylesheetObserver();
@@ -118,7 +121,7 @@ export const PopoutWindow: React.FC<PopoutWindowProps> = ({
childWindow.close();
}
};
}, [windowKey]);
}, [windowGeneration, windowKey]);
useEffect(() => {
if (!childState || childState.childWindow.closed) return;
childState.childWindow.document.title = title;
@@ -163,11 +166,11 @@ export const PopoutWindow: React.FC<PopoutWindowProps> = ({
}, [childState]);
const handleCloseClick = useCallback(() => {
if (!childState || childState.childWindow.closed) {
onClosedRef.current();
onClosedRef.current(childState?.windowGeneration ?? windowGeneration);
return;
}
childState.childWindow.close();
}, [childState]);
}, [childState, windowGeneration]);
if (!childState) {
return null;
}
@@ -14,3 +14,24 @@
flex: 1 1 auto;
min-height: 0;
}
.standaloneUserContent {
display: flex;
flex: 1 1 auto;
min-height: 0;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 16px;
color: var(--text-primary);
}
.standaloneUserName {
max-width: min(80%, 480px);
overflow: hidden;
font-size: 20px;
font-weight: 600;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1,10 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Channels from '@app/features/channel/state/Channels';
import {createNamedLoadableComponent} from '@app/features/platform/components/loadable/LoadableComponent';
import {PopoutWindow} from '@app/features/voice/components/popout/PopoutWindow';
import styles from '@app/features/voice/components/popout/VoicePopoutHost.module.css';
import {VoicePopoutScopeContext} from '@app/features/voice/components/popout/VoicePopoutScopeContext';
import {VoiceTilePopoutContent} from '@app/features/voice/components/popout/VoiceTilePopoutContent';
import {useVoiceEngineConnectionState} from '@app/features/voice/components/useVoiceEngineConnectionState';
import {VoiceCallView} from '@app/features/voice/components/VoiceCallView';
import {
@@ -12,12 +12,14 @@ import {
VoiceEngineConnectionState,
} from '@app/features/voice/engine/VoiceConnectionStateMachine';
import PopoutWindowManager, {
isVoicePopoutAlwaysOnTopSupported,
VOICE_CALL_POPOUT_DEFAULT_HEIGHT,
VOICE_CALL_POPOUT_DEFAULT_WIDTH,
VOICE_TILE_POPOUT_DEFAULT_HEIGHT,
VOICE_TILE_POPOUT_DEFAULT_WIDTH,
type VoiceCallPopoutDescriptor,
type VoicePopoutDescriptor,
type VoiceTilePopoutDescriptor,
} from '@app/features/voice/state/PopoutWindowManager';
import {VOICE_CALL_DESCRIPTOR} from '@app/features/voice/utils/VoiceMessageDescriptors';
import {useLingui} from '@lingui/react/macro';
@@ -25,6 +27,12 @@ import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect} from 'react';
const VoiceTilePopoutContent = createNamedLoadableComponent<{descriptor: VoiceTilePopoutDescriptor}>({
displayName: 'VoiceTilePopoutContent',
load: async () =>
(await import('@app/features/voice/components/popout/VoiceTilePopoutContent')).VoiceTilePopoutContent,
});
const VoiceCallPopoutContent = observer(function VoiceCallPopoutContent({
descriptor,
}: {
@@ -33,8 +41,8 @@ const VoiceCallPopoutContent = observer(function VoiceCallPopoutContent({
const channel = Channels.getChannel(descriptor.channelId);
useEffect(() => {
if (channel) return;
PopoutWindowManager.close(descriptor.key);
}, [channel, descriptor.key]);
PopoutWindowManager.close(descriptor.key, descriptor.generation);
}, [channel, descriptor.generation, descriptor.key]);
if (!channel) return null;
return (
<div className={styles.callContent} data-flx="voice.voice-popout-host.call-content">
@@ -49,18 +57,21 @@ const VoicePopoutWindowRenderer = observer(function VoicePopoutWindowRenderer({
descriptor: VoicePopoutDescriptor;
}) {
const key = descriptor.key;
const handleClosed = useCallback(() => {
PopoutWindowManager.handleWindowClosed(key);
}, [key]);
const handleClosed = useCallback(
(windowGeneration: number) => {
PopoutWindowManager.handleWindowClosed(key, windowGeneration);
},
[key],
);
const handleRestore = useCallback(() => {
PopoutWindowManager.close(key);
}, [key]);
PopoutWindowManager.close(key, descriptor.generation);
}, [descriptor.generation, key]);
const handleToggleAlwaysOnTop = useCallback(() => {
PopoutWindowManager.toggleAlwaysOnTop(key);
}, [key]);
const handleWindowOpened = useCallback(
(childWindow: Window) => {
PopoutWindowManager.attachWindow(key, childWindow);
(childWindow: Window, windowGeneration: number) => {
PopoutWindowManager.attachWindow(key, windowGeneration, childWindow);
},
[key],
);
@@ -71,12 +82,13 @@ const VoicePopoutWindowRenderer = observer(function VoicePopoutWindowRenderer({
return (
<PopoutWindow
windowKey={key}
windowGeneration={descriptor.generation}
title={title}
showTitlebarTitle={!isCallPopout}
width={isCallPopout ? VOICE_CALL_POPOUT_DEFAULT_WIDTH : VOICE_TILE_POPOUT_DEFAULT_WIDTH}
height={isCallPopout ? VOICE_CALL_POPOUT_DEFAULT_HEIGHT : VOICE_TILE_POPOUT_DEFAULT_HEIGHT}
isAlwaysOnTop={PopoutWindowManager.isAlwaysOnTop(key)}
onToggleAlwaysOnTop={handleToggleAlwaysOnTop}
onToggleAlwaysOnTop={isVoicePopoutAlwaysOnTopSupported() ? handleToggleAlwaysOnTop : undefined}
onRestore={handleRestore}
onClosed={handleClosed}
onWindowOpened={handleWindowOpened}
@@ -102,17 +114,20 @@ const VoicePopoutWindowRenderer = observer(function VoicePopoutWindowRenderer({
export const VoicePopoutHost: React.FC = observer(function VoicePopoutHost() {
const connectionState = asVoiceEngineConnectionState(useVoiceEngineConnectionState());
const hasOpenPopouts = PopoutWindowManager.openPopoutCount > 0;
const hasConnectionBoundPopouts = PopoutWindowManager.openPopouts.some(
(descriptor) => descriptor.kind === 'call' || descriptor.source !== 'user',
);
useEffect(() => {
if (connectionState !== VoiceEngineConnectionState.Disconnected) return;
if (!hasOpenPopouts) return;
PopoutWindowManager.closeAll();
}, [connectionState, hasOpenPopouts]);
if (!hasConnectionBoundPopouts) return;
PopoutWindowManager.closeConnectionBoundPopouts();
}, [connectionState, hasConnectionBoundPopouts]);
if (!hasOpenPopouts) return null;
return (
<>
{PopoutWindowManager.openPopouts.map((descriptor) => (
<VoicePopoutWindowRenderer
key={descriptor.key}
key={`${descriptor.key}:${descriptor.generation}`}
descriptor={descriptor}
data-flx="voice.voice-popout-host.voice-popout-window-renderer"
/>
@@ -1,18 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Avatar} from '@app/features/ui/components/Avatar';
import Users from '@app/features/user/state/Users';
import styles from '@app/features/voice/components/popout/VoicePopoutHost.module.css';
import {VoiceParticipantTile} from '@app/features/voice/components/VoiceParticipantTile';
import {useMaybeVoiceRoom} from '@app/features/voice/components/VoiceRoomContext';
import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/MediaEngineFacade';
import ScreenSharePublicationMigration from '@app/features/voice/engine/ScreenSharePublicationMigration';
import {useStoreVersion} from '@app/features/voice/engine/Store';
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
import PopoutWindowManager, {type VoiceTilePopoutDescriptor} from '@app/features/voice/state/PopoutWindowManager';
import {
isTrackReference,
type TrackReferenceOrPlaceholder,
useMaybeRoomContext,
useTracks,
} from '@livekit/components-react';
import {isTrackReference, type TrackReferenceOrPlaceholder, useTracks} from '@livekit/components-react';
import {type Room, RoomEvent, type Track} from 'livekit-client';
import {observer} from 'mobx-react-lite';
import type React from 'react';
@@ -21,8 +19,49 @@ import {useEffect, useMemo} from 'react';
const CAMERA_SOURCE = VoiceTrackSource.Camera as Track.Source;
const SCREEN_SHARE_SOURCE = VoiceTrackSource.ScreenShare as Track.Source;
function getDescriptorTrackSource(descriptor: VoiceTilePopoutDescriptor): Track.Source {
return descriptor.source === 'screen_share' ? SCREEN_SHARE_SOURCE : CAMERA_SOURCE;
function unsupportedVoiceTilePopoutSource(source: never): never {
throw new Error(`Unsupported voice tile popout source: ${String(source)}`);
}
function getDescriptorTrackSource(descriptor: VoiceTilePopoutDescriptor): Track.Source | null {
switch (descriptor.source) {
case 'user':
return null;
case 'camera':
return CAMERA_SOURCE;
case 'screen_share':
return SCREEN_SHARE_SOURCE;
default:
return unsupportedVoiceTilePopoutSource(descriptor.source);
}
}
function isVoiceTilePopoutTargetLive(
descriptor: VoiceTilePopoutDescriptor,
trackRef: TrackReferenceOrPlaceholder | null,
): boolean {
if (descriptor.source === 'user') return true;
const voiceState = MediaEngine.getVoiceStateByConnectionId(descriptor.connectionId);
if (voiceState && voiceState.user_id !== descriptor.userId) return false;
const connectionParticipant = MediaEngine.getParticipantByUserIdAndConnectionId(
descriptor.userId,
descriptor.connectionId,
);
const targetSource = getDescriptorTrackSource(descriptor);
const hasMatchingLiveKitTrack = Boolean(
trackRef &&
isTrackReference(trackRef) &&
trackRef.participant.identity === descriptor.participantIdentity &&
trackRef.source === targetSource,
);
switch (descriptor.source) {
case 'camera':
return Boolean(connectionParticipant?.isCameraEnabled || hasMatchingLiveKitTrack || voiceState?.self_video);
case 'screen_share':
return Boolean(connectionParticipant?.isScreenShareEnabled || hasMatchingLiveKitTrack || voiceState?.self_stream);
default:
return unsupportedVoiceTilePopoutSource(descriptor.source);
}
}
function useTilePopoutLiveKitTrackRef(
@@ -51,6 +90,7 @@ function useTilePopoutLiveKitTrackRef(
const screenSharePublicationMigrationVersion = ScreenSharePublicationMigration.version;
return useMemo(() => {
const targetSource = getDescriptorTrackSource(descriptor);
if (targetSource === null) return null;
const matchingTrackRefs = tracks.filter(
(trackRef) =>
trackRef.participant.identity === descriptor.participantIdentity && trackRef.source === targetSource,
@@ -77,18 +117,12 @@ const VoiceTilePopoutContentBase = observer(function VoiceTilePopoutContentBase(
trackRef,
}: VoiceTilePopoutContentBaseProps) {
useMediaEngineVersion();
const connectionParticipant = MediaEngine.getParticipantByUserIdAndConnectionId(
descriptor.userId,
descriptor.connectionId,
);
const isTrackLive =
descriptor.source === 'camera'
? Boolean(connectionParticipant?.isCameraEnabled)
: Boolean(connectionParticipant?.isScreenShareEnabled);
const isTrackLive = isVoiceTilePopoutTargetLive(descriptor, trackRef);
const standaloneUser = descriptor.source === 'user' && !trackRef ? Users.getUser(descriptor.userId) : undefined;
useEffect(() => {
if (isTrackLive) return;
PopoutWindowManager.close(descriptor.key);
}, [isTrackLive, descriptor.key]);
PopoutWindowManager.close(descriptor.key, descriptor.generation);
}, [isTrackLive, descriptor.generation, descriptor.key]);
return (
<div className={styles.tileContent} data-flx="voice.voice-tile-popout-content.tile-content">
{trackRef && (
@@ -101,6 +135,25 @@ const VoiceTilePopoutContentBase = observer(function VoiceTilePopoutContentBase(
data-flx="voice.voice-tile-popout-content.voice-participant-tile"
/>
)}
{standaloneUser && (
<div
className={styles.standaloneUserContent}
data-flx="voice.popout.voice-tile-popout-content.voice-tile-popout-content-base.standalone-user-content"
>
<Avatar
user={standaloneUser}
size={128}
guildId={descriptor.guildId}
data-flx="voice.popout.voice-tile-popout-content.voice-tile-popout-content-base.avatar"
/>
<div
className={styles.standaloneUserName}
data-flx="voice.popout.voice-tile-popout-content.voice-tile-popout-content-base.standalone-user-name"
>
{descriptor.title}
</div>
</div>
)}
</div>
);
});
@@ -124,7 +177,7 @@ const VoiceTilePopoutContentWithLiveKit = observer(function VoiceTilePopoutConte
export const VoiceTilePopoutContent: React.FC<{descriptor: VoiceTilePopoutDescriptor}> = observer(
function VoiceTilePopoutContent({descriptor}) {
const room = useMaybeRoomContext() ?? null;
const room = useMaybeVoiceRoom() ?? null;
if (!room) {
return (
<VoiceTilePopoutContentBase
@@ -0,0 +1,180 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import PopoutWindowManager, {
getVoiceCallPopoutKey,
getVoiceTilePopoutKey,
type VoiceTilePopoutSource,
} from '@app/features/voice/state/PopoutWindowManager';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
interface PendingAlwaysOnTopCall {
key: string;
flag: boolean;
resolve: () => void;
reject: (error: Error) => void;
}
let pendingAlwaysOnTopCalls: Array<PendingAlwaysOnTopCall> = [];
function installElectronApi(): void {
pendingAlwaysOnTopCalls = [];
(window as unknown as {electron: unknown}).electron = {
popoutFocus: () => Promise.resolve(true),
popoutSetAlwaysOnTop: (key: string, flag: boolean) =>
new Promise<boolean>((resolve, reject) => {
pendingAlwaysOnTopCalls.push({key, flag, resolve: () => resolve(true), reject});
}),
};
}
function openCallPopout(channelId: string): string {
expect(PopoutWindowManager.openCallPopout({channelId, guildId: null, title: channelId})).toBe(true);
return getVoiceCallPopoutKey(channelId);
}
function openTilePopout(participantIdentity: string, source: VoiceTilePopoutSource): string {
expect(
PopoutWindowManager.openTilePopout({
participantIdentity,
source,
userId: 'user-1',
connectionId: 'connection-1',
channelId: 'channel-1',
guildId: null,
title: participantIdentity,
}),
).toBe(true);
return getVoiceTilePopoutKey(participantIdentity, source);
}
function generationOf(key: string): number {
const descriptor = PopoutWindowManager.popouts[key];
if (!descriptor) throw new Error(`Expected popout ${key} to be open`);
return descriptor.generation;
}
beforeEach(() => {
installElectronApi();
PopoutWindowManager.closeAll();
});
afterEach(() => {
PopoutWindowManager.closeAll();
delete (window as unknown as {electron?: unknown}).electron;
});
describe('popout generations', () => {
test('allocates a fresh generation every time a key is reopened', () => {
const key = openCallPopout('channel-a');
const firstGeneration = generationOf(key);
PopoutWindowManager.close(key, firstGeneration);
openCallPopout('channel-a');
expect(generationOf(key)).not.toBe(firstGeneration);
});
test('ignores handleWindowClosed from a superseded window', () => {
const key = openCallPopout('channel-a');
const firstGeneration = generationOf(key);
PopoutWindowManager.close(key, firstGeneration);
openCallPopout('channel-a');
const secondGeneration = generationOf(key);
PopoutWindowManager.handleWindowClosed(key, firstGeneration);
expect(PopoutWindowManager.isOpen(key)).toBe(true);
PopoutWindowManager.handleWindowClosed(key, secondGeneration);
expect(PopoutWindowManager.isOpen(key)).toBe(false);
});
test('ignores close from a superseded window', () => {
const key = openCallPopout('channel-a');
const firstGeneration = generationOf(key);
PopoutWindowManager.close(key, firstGeneration);
openCallPopout('channel-a');
PopoutWindowManager.close(key, firstGeneration);
expect(PopoutWindowManager.isOpen(key)).toBe(true);
});
test('ignores attachWindow from a superseded window', () => {
const key = openCallPopout('channel-a');
const staleGeneration = generationOf(key);
PopoutWindowManager.close(key, staleGeneration);
openCallPopout('channel-a');
const staleChildWindow = {closed: false, focus: vi.fn(), close: vi.fn()};
PopoutWindowManager.attachWindow(key, staleGeneration, staleChildWindow);
PopoutWindowManager.close(key);
expect(staleChildWindow.close).not.toHaveBeenCalled();
});
test('closes the child window attached for the current generation', () => {
const key = openCallPopout('channel-a');
const childWindow = {closed: false, focus: vi.fn(), close: vi.fn()};
PopoutWindowManager.attachWindow(key, generationOf(key), childWindow);
PopoutWindowManager.close(key);
expect(childWindow.close).toHaveBeenCalledTimes(1);
});
});
describe('always on top', () => {
test('commits the pinned state only after the desktop call resolves', async () => {
const key = openCallPopout('channel-a');
PopoutWindowManager.setAlwaysOnTop(key, true);
expect(pendingAlwaysOnTopCalls).toHaveLength(1);
expect(PopoutWindowManager.isAlwaysOnTop(key)).toBe(false);
pendingAlwaysOnTopCalls[0]?.resolve();
await vi.waitFor(() => expect(PopoutWindowManager.isAlwaysOnTop(key)).toBe(true));
});
test('leaves the pinned state untouched when the desktop call rejects', async () => {
const key = openCallPopout('channel-a');
PopoutWindowManager.setAlwaysOnTop(key, true);
pendingAlwaysOnTopCalls[0]?.reject(new Error('popout pin failed'));
await vi.waitFor(() => expect(pendingAlwaysOnTopCalls).toHaveLength(1));
expect(PopoutWindowManager.isAlwaysOnTop(key)).toBe(false);
});
test('serializes concurrent toggles and applies the last requested value', async () => {
const key = openCallPopout('channel-a');
PopoutWindowManager.setAlwaysOnTop(key, true);
PopoutWindowManager.setAlwaysOnTop(key, false);
expect(pendingAlwaysOnTopCalls).toHaveLength(1);
expect(pendingAlwaysOnTopCalls[0]?.flag).toBe(true);
pendingAlwaysOnTopCalls[0]?.resolve();
await vi.waitFor(() => expect(pendingAlwaysOnTopCalls).toHaveLength(2));
expect(pendingAlwaysOnTopCalls[1]?.flag).toBe(false);
pendingAlwaysOnTopCalls[1]?.resolve();
await vi.waitFor(() => expect(PopoutWindowManager.isAlwaysOnTop(key)).toBe(false));
});
test('toggles against the pending desired value rather than the committed one', () => {
const key = openCallPopout('channel-a');
PopoutWindowManager.toggleAlwaysOnTop(key);
expect(pendingAlwaysOnTopCalls[0]?.flag).toBe(true);
PopoutWindowManager.toggleAlwaysOnTop(key);
expect(pendingAlwaysOnTopCalls).toHaveLength(1);
PopoutWindowManager.toggleAlwaysOnTop(key);
expect(pendingAlwaysOnTopCalls).toHaveLength(1);
});
test('drops in-flight operations when the popout is removed', async () => {
const key = openCallPopout('channel-a');
PopoutWindowManager.setAlwaysOnTop(key, true);
PopoutWindowManager.close(key);
pendingAlwaysOnTopCalls[0]?.resolve();
await vi.waitFor(() => expect(pendingAlwaysOnTopCalls).toHaveLength(1));
expect(PopoutWindowManager.isAlwaysOnTop(key)).toBe(false);
});
});
describe('closeConnectionBoundPopouts', () => {
test('keeps standalone user popouts and closes track-bound ones', () => {
const callKey = openCallPopout('channel-a');
const cameraKey = openTilePopout('participant-1', 'camera');
const screenShareKey = openTilePopout('participant-1', 'screen_share');
const userKey = openTilePopout('participant-2', 'user');
PopoutWindowManager.closeConnectionBoundPopouts();
expect(PopoutWindowManager.isOpen(callKey)).toBe(false);
expect(PopoutWindowManager.isOpen(cameraKey)).toBe(false);
expect(PopoutWindowManager.isOpen(screenShareKey)).toBe(false);
expect(PopoutWindowManager.isOpen(userKey)).toBe(true);
});
});
@@ -18,6 +18,7 @@ export type VoiceTilePopoutSource = 'user' | 'camera' | 'screen_share';
export interface VoiceTilePopoutDescriptor {
kind: 'tile';
key: string;
generation: number;
participantIdentity: string;
source: VoiceTilePopoutSource;
userId: string;
@@ -30,6 +31,7 @@ export interface VoiceTilePopoutDescriptor {
export interface VoiceCallPopoutDescriptor {
kind: 'call';
key: string;
generation: number;
channelId: string;
guildId: string | null;
title: string;
@@ -47,8 +49,12 @@ export function getVoiceCallPopoutKey(channelId: string): string {
export function isVoicePopoutSupported(): boolean {
const electronApi = getElectronAPI();
if (!electronApi) return false;
return typeof electronApi.popoutSetAlwaysOnTop === 'function';
if (electronApi) return typeof electronApi.popoutSetAlwaysOnTop === 'function';
return typeof window !== 'undefined' && typeof window.open === 'function';
}
export function isVoicePopoutAlwaysOnTopSupported(): boolean {
return typeof getElectronAPI()?.popoutSetAlwaysOnTop === 'function';
}
interface PopoutChildWindow {
@@ -57,13 +63,28 @@ interface PopoutChildWindow {
close: () => void;
}
interface AlwaysOnTopOperationState {
readonly descriptor: VoicePopoutDescriptor;
desired: boolean;
}
class PopoutWindowManagerStore {
popouts: Record<string, VoicePopoutDescriptor> = {};
alwaysOnTopKeys: Record<string, true> = {};
private readonly childWindows = new Map<string, PopoutChildWindow>();
private readonly alwaysOnTopOperations = new Map<string, AlwaysOnTopOperationState>();
private nextPopoutGeneration = 1;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
makeAutoObservable<this, 'alwaysOnTopOperations' | 'childWindows' | 'nextPopoutGeneration'>(
this,
{
alwaysOnTopOperations: false,
childWindows: false,
nextPopoutGeneration: false,
},
{autoBind: true},
);
}
get openPopouts(): Array<VoicePopoutDescriptor> {
@@ -101,18 +122,18 @@ class PopoutWindowManagerStore {
return this.alwaysOnTopKeys[key] === true;
}
openTilePopout(options: Omit<VoiceTilePopoutDescriptor, 'kind' | 'key'>): boolean {
openTilePopout(options: Omit<VoiceTilePopoutDescriptor, 'kind' | 'key' | 'generation'>): boolean {
const key = getVoiceTilePopoutKey(options.participantIdentity, options.source);
return this.register({kind: 'tile', key, ...options});
return this.register({kind: 'tile', key, generation: this.allocatePopoutGeneration(), ...options});
}
openCallPopout(options: Omit<VoiceCallPopoutDescriptor, 'kind' | 'key'>): boolean {
openCallPopout(options: Omit<VoiceCallPopoutDescriptor, 'kind' | 'key' | 'generation'>): boolean {
const existingCallPopout = this.callPopout;
const key = getVoiceCallPopoutKey(options.channelId);
if (existingCallPopout && existingCallPopout.key !== key) {
this.close(existingCallPopout.key);
}
return this.register({kind: 'call', key, ...options});
return this.register({kind: 'call', key, generation: this.allocatePopoutGeneration(), ...options});
}
focus(key: string): void {
@@ -127,8 +148,8 @@ class PopoutWindowManagerStore {
}
}
attachWindow(key: string, childWindow: PopoutChildWindow | null): void {
if (!this.isOpen(key)) return;
attachWindow(key: string, generation: number, childWindow: PopoutChildWindow | null): void {
if (!this.isCurrentPopout(key, generation)) return;
if (childWindow === null) {
this.childWindows.delete(key);
return;
@@ -137,32 +158,39 @@ class PopoutWindowManagerStore {
}
setAlwaysOnTop(key: string, flag: boolean): void {
if (!this.isOpen(key)) return;
const electronApi = getElectronAPI();
void electronApi?.popoutSetAlwaysOnTop?.(key, flag).catch((error) => {
logger.warn('Failed to toggle popout always-on-top', {key, flag, error});
});
runInAction(() => {
if (flag) {
this.alwaysOnTopKeys = {...this.alwaysOnTopKeys, [key]: true};
} else {
const next = {...this.alwaysOnTopKeys};
delete next[key];
this.alwaysOnTopKeys = next;
}
});
const descriptor = this.popouts[key];
if (!descriptor) return;
if (!isVoicePopoutAlwaysOnTopSupported()) {
logger.warn('Ignored always-on-top request: desktop popout API unavailable', {key, flag});
return;
}
const currentOperation = this.alwaysOnTopOperations.get(key);
if (currentOperation?.descriptor === descriptor) {
currentOperation.desired = flag;
return;
}
const operationState: AlwaysOnTopOperationState = {descriptor, desired: flag};
this.alwaysOnTopOperations.set(key, operationState);
void this.applyAlwaysOnTopOperation(key, operationState);
}
toggleAlwaysOnTop(key: string): void {
this.setAlwaysOnTop(key, !this.isAlwaysOnTop(key));
const descriptor = this.popouts[key];
if (!descriptor) return;
const currentOperation = this.alwaysOnTopOperations.get(key);
const currentFlag =
currentOperation?.descriptor === descriptor ? currentOperation.desired : this.isAlwaysOnTop(key);
this.setAlwaysOnTop(key, !currentFlag);
}
handleWindowClosed(key: string): void {
handleWindowClosed(key: string, generation: number): void {
if (!this.isCurrentPopout(key, generation)) return;
this.childWindows.delete(key);
this.remove(key);
}
close(key: string): void {
close(key: string, generation?: number): void {
if (generation !== undefined && !this.isCurrentPopout(key, generation)) return;
const childWindow = this.childWindows.get(key);
this.childWindows.delete(key);
this.remove(key);
@@ -177,6 +205,13 @@ class PopoutWindowManagerStore {
}
}
closeConnectionBoundPopouts(): void {
for (const descriptor of this.openPopouts) {
if (descriptor.kind === 'tile' && descriptor.source === 'user') continue;
this.close(descriptor.key);
}
}
private register(descriptor: VoicePopoutDescriptor): boolean {
if (this.isOpen(descriptor.key)) {
this.focus(descriptor.key);
@@ -190,14 +225,83 @@ class PopoutWindowManagerStore {
logger.warn('Ignored popout request: popout capacity reached', {key: descriptor.key});
return false;
}
if (!getElectronAPI()) {
const width = descriptor.kind === 'call' ? VOICE_CALL_POPOUT_DEFAULT_WIDTH : VOICE_TILE_POPOUT_DEFAULT_WIDTH;
const height = descriptor.kind === 'call' ? VOICE_CALL_POPOUT_DEFAULT_HEIGHT : VOICE_TILE_POPOUT_DEFAULT_HEIGHT;
const childWindow = window.open('about:blank', descriptor.key, `width=${width},height=${height}`);
if (!childWindow) {
logger.warn('Failed to open browser popout window', {key: descriptor.key});
return false;
}
this.childWindows.set(descriptor.key, childWindow);
}
runInAction(() => {
this.popouts = {...this.popouts, [descriptor.key]: descriptor};
});
return true;
}
private allocatePopoutGeneration(): number {
const generation = this.nextPopoutGeneration;
if (!Number.isSafeInteger(generation)) {
throw new Error('Voice popout generation exhausted');
}
this.nextPopoutGeneration += 1;
return generation;
}
private isCurrentPopout(key: string, generation: number): boolean {
return this.popouts[key]?.generation === generation;
}
private async applyAlwaysOnTopOperation(key: string, operationState: AlwaysOnTopOperationState): Promise<void> {
while (this.alwaysOnTopOperations.get(key) === operationState) {
const {descriptor} = operationState;
if (!this.isCurrentPopout(key, descriptor.generation)) return;
const flag = operationState.desired;
const operation = getElectronAPI()?.popoutSetAlwaysOnTop?.(key, flag);
if (!operation) {
this.alwaysOnTopOperations.delete(key);
return;
}
try {
await operation;
} catch (error) {
if (
this.alwaysOnTopOperations.get(key) !== operationState ||
!this.isCurrentPopout(key, descriptor.generation)
) {
return;
}
logger.warn('Failed to toggle popout always-on-top', {key, flag, error});
if (operationState.desired === flag) {
this.alwaysOnTopOperations.delete(key);
return;
}
continue;
}
if (this.alwaysOnTopOperations.get(key) !== operationState || !this.isCurrentPopout(key, descriptor.generation)) {
return;
}
runInAction(() => {
if (flag) {
this.alwaysOnTopKeys = {...this.alwaysOnTopKeys, [key]: true};
} else {
const next = {...this.alwaysOnTopKeys};
delete next[key];
this.alwaysOnTopKeys = next;
}
});
if (operationState.desired === flag) {
this.alwaysOnTopOperations.delete(key);
return;
}
}
}
private remove(key: string): void {
if (!this.isOpen(key)) return;
this.alwaysOnTopOperations.delete(key);
runInAction(() => {
const nextPopouts = {...this.popouts};
delete nextPopouts[key];