mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
feat(voice): open voice popouts in the browser (#2400)
This commit is contained in:
@@ -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)
|
||||
|
||||
+8
-10
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user