mirror of
https://gitcode.com/GitHub_Trending/ji/jitsi-meet.git
synced 2026-01-06 23:02:28 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16c45c15c8 | ||
|
|
5d5d6c3068 | ||
|
|
19399ec123 | ||
|
|
3c27f15490 | ||
|
|
607073c669 | ||
|
|
f92ee57f9c | ||
|
|
32331d7465 | ||
|
|
88685c43fb | ||
|
|
348573b254 | ||
|
|
1a05991b8c | ||
|
|
c3f2390642 | ||
|
|
7cf8902fdd | ||
|
|
3e4fb82d58 | ||
|
|
057dc0e4d2 | ||
|
|
ce4cbacceb | ||
|
|
af4f122602 | ||
|
|
b7f5b8ecd2 | ||
|
|
d15e51adbd | ||
|
|
affef1ac66 | ||
|
|
7f95dbb6d6 | ||
|
|
8065da61c7 | ||
|
|
b6df08f072 |
@@ -89,6 +89,9 @@ var config = {
|
||||
// Enables use of getDisplayMedia in electron
|
||||
// electronUseGetDisplayMedia: false,
|
||||
|
||||
// Enables AV1 codec for FF. Note: By default it is disabled.
|
||||
// enableAV1ForFF: false,
|
||||
|
||||
// Enables the use of the codec selection API supported by the browsers .
|
||||
// enableCodecSelectionAPI: false,
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ external_services = {
|
||||
|
||||
cross_domain_bosh = false;
|
||||
consider_bosh_secure = true;
|
||||
consider_websocket_secure = true;
|
||||
-- https_ports = { }; -- Remove this line to prevent listening on port 5284
|
||||
|
||||
-- by default prosody 0.12 sends cors headers, if you want to disable it uncomment the following (the config is available on 0.12.1)
|
||||
|
||||
@@ -1111,7 +1111,7 @@
|
||||
"incomingMessage": "Příchozí zpráva",
|
||||
"language": "Jazyk",
|
||||
"loggedIn": "Přihlášen/a jako {{name}}",
|
||||
"maxStageParticipants": "Maximální počet účastníků, které lze připnout na hlavní pódium (EXPERIMENTÁLNÍ)",
|
||||
"maxStageParticipants": "Maximální počet účastníků, které lze připnout na hlavní pódium",
|
||||
"microphones": "Mikrofony",
|
||||
"moderator": "Moderátor",
|
||||
"moderatorOptions": "Možnosti moderátora",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"Remove": "Entfernen",
|
||||
"Share": "Teilen",
|
||||
"Submit": "OK",
|
||||
"Understand": "Verstanden",
|
||||
"WaitForHostMsg": "Die Konferenz wurde noch nicht gestartet. Falls Sie die Konferenz leiten, authentifizieren Sie sich bitte. Warten Sie andernfalls, bis die Konferenz gestartet wird.",
|
||||
"WaitForHostNoAuthMsg": "Die Konferenz wurde noch nicht gestartet. Bitte warten Sie, bis die Konferenz gestartet wird.",
|
||||
"WaitingForHostButton": "Auf Moderation warten",
|
||||
@@ -393,6 +394,8 @@
|
||||
"recentlyUsedObjects": "Ihre zuletzt verwendeten Objekte",
|
||||
"recording": "Aufnahme",
|
||||
"recordingDisabledBecauseOfActiveLiveStreamingTooltip": "Während eines Livestreams nicht möglich",
|
||||
"recordingInProgressDescription": "Diese Konferenz wird aufgezeichnet. Ihr Ton und Video ist deaktiviert, wenn Sie es aktivieren, stimmen Sie der Aufzeichnung zu.",
|
||||
"recordingInProgressTitle": "Aufnahme läuft",
|
||||
"rejoinNow": "Jetzt erneut beitreten",
|
||||
"remoteControlAllowedMessage": "{{user}} hat die Anfrage zur Fernsteuerung angenommen!",
|
||||
"remoteControlDeniedMessage": "{{user}} hat die Anfrage zur Fernsteuerung verweigert!",
|
||||
@@ -749,7 +752,8 @@
|
||||
"dataChannelClosedDescriptionWithAudio": "Die Steuerungsverbindung (Bridge Channel) wurde unterbrochen, daher können Video- und Tonprobleme auftreten.",
|
||||
"dataChannelClosedWithAudio": "Ton- und Videoqualität können beeinträchtigt sein",
|
||||
"disabledIframe": "Die Einbettung ist nur für Demo-Zwecke vorgesehen. Diese Konferenz wird in {{timeout}} Minuten beendet.",
|
||||
"disabledIframeSecondary": "Die Einbettung von {{domain}} ist nur für Demo-Zwecke vorgesehen. Diese Konferenz wird in {{timeout}} Minuten beendet. Bitte nutzen Sie <a href='{{jaasDomain}}' rel='noopener noreferrer' target='_blank'>Jitsi as a Service</a> für produktive Zwecke!",
|
||||
"disabledIframeSecondaryNative": "Die Einbettung von {{domain}} ist nur für Demo-Zwecke vorgesehen. Diese Konferenz wird in {{timeout}} Minuten beendet.",
|
||||
"disabledIframeSecondaryWeb": "Die Einbettung von {{domain}} ist nur für Demo-Zwecke vorgesehen. Diese Konferenz wird in {{timeout}} Minuten beendet. Bitte nutzen Sie <a href='{{jaasDomain}}' rel='noopener noreferrer' target='_blank'>Jitsi as a Service</a> für produktive Zwecke!",
|
||||
"disconnected": "getrennt",
|
||||
"displayNotifications": "Benachrichtigungen anzeigen für",
|
||||
"dontRemindMe": "Nicht erinnern",
|
||||
@@ -877,6 +881,7 @@
|
||||
"waitingLobby": "In der Lobby ({{count}})"
|
||||
},
|
||||
"search": "Suche Anwesende",
|
||||
"searchDescription": "Tippen Sie um die Anwesendenliste zu filtern",
|
||||
"title": "Anwesende"
|
||||
},
|
||||
"passwordDigitsOnly": "Bis zu {{number}} Ziffern",
|
||||
@@ -1104,6 +1109,7 @@
|
||||
"signedIn": "Momentan wird auf Kalendertermine von {{email}} zugegriffen. Klicken Sie auf die folgende Schaltfläche „Trennen“, um den Zugriff auf die Kalendertermine zu stoppen.",
|
||||
"title": "Kalender"
|
||||
},
|
||||
"chatWithPermissions": "Chat mit Freigaben",
|
||||
"desktopShareFramerate": "Framerate für Bildschirmfreigabe",
|
||||
"desktopShareHighFpsWarning": "Eine höhere Framerate könnte sich auf Ihre Datenrate auswirken. Sie müssen die Bildschirmfreigabe neustarten, damit die Einstellung übernommen wird.",
|
||||
"desktopShareWarning": "Sie müssen die Bildschirmfreigabe neustarten, damit die Einstellung übernommen wird.",
|
||||
@@ -1192,6 +1198,7 @@
|
||||
"neutral": "Neutral",
|
||||
"sad": "Traurig",
|
||||
"search": "Suche",
|
||||
"searchDescription": "Tippen Sie um die Anwesendenliste zu filtern",
|
||||
"searchHint": "Suche Anwesende",
|
||||
"seconds": "{{count}} Sek.",
|
||||
"speakerStats": "Sprechstatistik",
|
||||
@@ -1270,7 +1277,7 @@
|
||||
"muteGUMPending": "Verbinde Ihr Mikrofon",
|
||||
"noiseSuppression": "Rauschunterdrückung",
|
||||
"openChat": "Chat öffnen",
|
||||
"participants": "Anwesende",
|
||||
"participants": "Anwesenheitsliste öffnen. {{participantsCount}} anwesend",
|
||||
"pip": "Bild-in-Bild-Modus ein-/ausschalten",
|
||||
"privateMessage": "Private Nachricht senden",
|
||||
"profile": "Profil bearbeiten",
|
||||
@@ -1408,7 +1415,8 @@
|
||||
"ccButtonTooltip": "Untertitel ein-/ausschalten",
|
||||
"expandedLabel": "Transkribieren ist derzeit eingeschaltet",
|
||||
"failed": "Transkribieren fehlgeschlagen",
|
||||
"labelToolTip": "Die Konferenz wird transkribiert",
|
||||
"labelTooltip": "Die Konferenz wird transkribiert",
|
||||
"labelTooltipExtra": "Zusätzlich wird das Transkript später verfügbar sein.",
|
||||
"sourceLanguageDesc": "Aktuell ist die Sprache der Konferenz auf <b>{{sourceLanguage}}</b> eingestellt. <br/> Sie könne dies hier ",
|
||||
"sourceLanguageHere": "ändern",
|
||||
"start": "Anzeige der Untertitel starten",
|
||||
|
||||
@@ -984,7 +984,7 @@
|
||||
"incomingMessage": "Εισερχόμενο μήνυμα",
|
||||
"language": "Γλώσσα",
|
||||
"loggedIn": "Συνδέθηκε ως {{name}}",
|
||||
"maxStageParticipants": "Μέγιστος αριθμός συμμετεχόντων που μπορούν να διατηρηθούν στην κύρια σκηνή (ΠΕΙΡΑΜΑΤΙΚΟ)",
|
||||
"maxStageParticipants": "Μέγιστος αριθμός συμμετεχόντων που μπορούν να διατηρηθούν στην κύρια σκηνή",
|
||||
"microphones": "Μικρόφωνα",
|
||||
"moderator": "Συντονιστής",
|
||||
"moderatorOptions": "Επιλογές συντονιστή",
|
||||
|
||||
@@ -1070,7 +1070,7 @@
|
||||
"incomingMessage": "Envena mesaĝo",
|
||||
"language": "Lingvo",
|
||||
"loggedIn": "Ensalutinta kiels {{name}}",
|
||||
"maxStageParticipants": "Maksimuma nombro da partoprenantoj, kiuj povas esti alpinglitaj al la ĉefa scenejo (EXPERIMENTA)",
|
||||
"maxStageParticipants": "Maksimuma nombro da partoprenantoj, kiuj povas esti alpinglitaj al la ĉefa scenejo",
|
||||
"microphones": "Mikrofonoj",
|
||||
"moderator": "Kunvenestro",
|
||||
"moderatorOptions": "Kunvenestaj agordoj",
|
||||
|
||||
@@ -1026,7 +1026,7 @@
|
||||
"incomingMessage": "پیام ورودی",
|
||||
"language": "زبان",
|
||||
"loggedIn": "واردشده به عنوان {{name}}",
|
||||
"maxStageParticipants": "بیشینه تعداد شرکتکنندگانی که میتوانند به صحنه اصلی سنجاق شوند (<b>آزمایشی</b>)",
|
||||
"maxStageParticipants": "بیشینه تعداد شرکتکنندگانی که میتوانند به صحنه اصلی سنجاق شوند",
|
||||
"microphones": "میکروفونها",
|
||||
"moderator": "مدیر",
|
||||
"moderatorOptions": "گزینههای مدیر",
|
||||
|
||||
@@ -1111,7 +1111,7 @@
|
||||
"incomingMessage": "un message arrive",
|
||||
"language": "Langue",
|
||||
"loggedIn": "Connecté en tant que {{name}}",
|
||||
"maxStageParticipants": "Nombre maximum de participants pouvant être épinglé sur l’affichage principal (EXPÉRIMENTAL)",
|
||||
"maxStageParticipants": "Nombre maximum de participants pouvant être épinglé sur l’affichage principal",
|
||||
"microphones": "Microphones",
|
||||
"moderator": "Modérateur",
|
||||
"moderatorOptions": "Options de modérateur",
|
||||
|
||||
@@ -1077,7 +1077,7 @@
|
||||
"incomingMessage": "un message arrive",
|
||||
"language": "Langue",
|
||||
"loggedIn": "Connecté en tant que {{name}}",
|
||||
"maxStageParticipants": "Nombre maximum de participants pouvant être épinglé sur l’affichage principal (EXPÉRIMENTAL)",
|
||||
"maxStageParticipants": "Nombre maximum de participants pouvant être épinglé sur l’affichage principal",
|
||||
"microphones": "Microphones",
|
||||
"moderator": "Modérateur",
|
||||
"moderatorOptions": "Options de modérateur",
|
||||
|
||||
@@ -1088,7 +1088,7 @@
|
||||
"incomingMessage": "Pesan masuk",
|
||||
"language": "Bahasa",
|
||||
"loggedIn": "Masuk sebagai {{name}}",
|
||||
"maxStageParticipants": "Jumlah maksimum peserta yang dapat ditampilkan di panggung utama (PERCOBAAN)",
|
||||
"maxStageParticipants": "Jumlah maksimum peserta yang dapat ditampilkan di panggung utama",
|
||||
"microphones": "Mikrofon",
|
||||
"moderator": "Moderator",
|
||||
"moderatorOptions": "Opsi moderator",
|
||||
|
||||
@@ -1069,7 +1069,7 @@
|
||||
"incomingMessage": "Móttekin skilaboð",
|
||||
"language": "Tungumál",
|
||||
"loggedIn": "Skráð inn sem {{name}}",
|
||||
"maxStageParticipants": "Hámarksfjöldi þátttakenda sem hægt er að festa á aðalgluggann (Á TILRAUNASTIGI)",
|
||||
"maxStageParticipants": "Hámarksfjöldi þátttakenda sem hægt er að festa á aðalgluggann",
|
||||
"microphones": "Hljóðnemar",
|
||||
"moderator": "Stjórnandi",
|
||||
"moderatorOptions": "Valkostir umsjónarmanns",
|
||||
|
||||
@@ -1110,7 +1110,7 @@
|
||||
"incomingMessage": "수신 메시지",
|
||||
"language": "언어",
|
||||
"loggedIn": "{{name}}으로 로그인",
|
||||
"maxStageParticipants": "메인 스테이지에 고정할 수 있는 최대 참가자 수 (실험적 기능)",
|
||||
"maxStageParticipants": "메인 스테이지에 고정할 수 있는 최대 참가자 수",
|
||||
"microphones": "마이크",
|
||||
"moderator": "진행자",
|
||||
"moderatorOptions": "진행자 옵션",
|
||||
|
||||
@@ -1117,7 +1117,7 @@
|
||||
"incomingMessage": "Ienākošā ziņa",
|
||||
"language": "Valoda",
|
||||
"loggedIn": "Ierakstījies kā {{name}}",
|
||||
"maxStageParticipants": "Maksimālais dalībnieku skaits, kurus var piespraust galvenajai skatuvei (EKSPERIMENTĀLS)",
|
||||
"maxStageParticipants": "Maksimālais dalībnieku skaits, kurus var piespraust galvenajai skatuvei",
|
||||
"microphones": "Mikrofoni",
|
||||
"moderator": "Moderators",
|
||||
"moderatorOptions": "Moderatora opcijas",
|
||||
|
||||
@@ -997,7 +997,7 @@
|
||||
"incomingMessage": "Ирсэн мессэж",
|
||||
"language": "Хэл",
|
||||
"loggedIn": "{{name}} нэвтэрсэн",
|
||||
"maxStageParticipants": "Үндсэн тайз руу гарах оролцогчийн хамгийн их тоо(Туршилтынх)",
|
||||
"maxStageParticipants": "Үндсэн тайз руу гарах оролцогчийн хамгийн их тоо",
|
||||
"microphones": "Микрофон",
|
||||
"moderator": "Зохицуулагч",
|
||||
"moderatorOptions": "Зохицуулагчийн сонголт",
|
||||
|
||||
@@ -1111,7 +1111,7 @@
|
||||
"incomingMessage": "Innkommende melding",
|
||||
"language": "Språk",
|
||||
"loggedIn": "Logget inn som {{name}}",
|
||||
"maxStageParticipants": "Maksimalt antall deltakere som kan festes til hovedscenen (EKSPERIMENTELL)",
|
||||
"maxStageParticipants": "Maksimalt antall deltakere som kan festes til hovedscenen",
|
||||
"microphones": "Mikrofoner",
|
||||
"moderator": "Moderator",
|
||||
"moderatorOptions": "Moderatoralternativer",
|
||||
|
||||
@@ -1111,7 +1111,7 @@
|
||||
"incomingMessage": "Innkommende melding",
|
||||
"language": "Språk",
|
||||
"loggedIn": "Logget inn som {{name}}",
|
||||
"maxStageParticipants": "Maksimalt antall deltakere som kan festes til hovedscenen (EKSPERIMENTELL)",
|
||||
"maxStageParticipants": "Maksimalt antall deltakere som kan festes til hovedscenen",
|
||||
"microphones": "Mikrofoner",
|
||||
"moderator": "Moderator",
|
||||
"moderatorOptions": "Moderatoralternativer",
|
||||
|
||||
@@ -1111,7 +1111,7 @@
|
||||
"incomingMessage": "Messatge dintrant",
|
||||
"language": "Lenga",
|
||||
"loggedIn": "Session a {{name}}",
|
||||
"maxStageParticipants": "Nombre maximal de participants que se pòt penjar a la scèna principala (EXPERIMENTAL)",
|
||||
"maxStageParticipants": "Nombre maximal de participants que se pòt penjar a la scèna principala",
|
||||
"microphones": "Microfòns",
|
||||
"moderator": "Moderator",
|
||||
"moderatorOptions": "Opcions de moderacion",
|
||||
|
||||
@@ -1097,7 +1097,7 @@
|
||||
"incomingMessage": "Receber uma mensagem",
|
||||
"language": "Idioma",
|
||||
"loggedIn": "Sessão iniciada como {{name}}",
|
||||
"maxStageParticipants": "Número máximo de participantes que podem ser afixados (EXPERIMENTAL)",
|
||||
"maxStageParticipants": "Número máximo de participantes que podem ser afixados",
|
||||
"microphones": "Microfones",
|
||||
"moderator": "Moderador",
|
||||
"moderatorOptions": "Opções de moderador",
|
||||
|
||||
@@ -1067,7 +1067,7 @@
|
||||
"incomingMessage": "Mensagem recebida",
|
||||
"language": "Idioma",
|
||||
"loggedIn": "Conectado como {{name}}",
|
||||
"maxStageParticipants": "Número máximo de participantes que podem ser fixados no palco principal (EXPERIMENTAL)",
|
||||
"maxStageParticipants": "Número máximo de participantes que podem ser fixados no palco principal",
|
||||
"microphones": "Microfones",
|
||||
"moderator": "Moderador",
|
||||
"moderatorOptions": "Opções de moderador",
|
||||
|
||||
@@ -1083,7 +1083,7 @@
|
||||
"incomingMessage": "Входящее сообщение",
|
||||
"language": "Язык",
|
||||
"loggedIn": "Вошел как {{name}}",
|
||||
"maxStageParticipants": "Максимальное количество участников, которых можно закрепить на главной сцене (ЭКСПЕРИМЕНТАЛЬНО)",
|
||||
"maxStageParticipants": "Максимальное количество участников, которых можно закрепить на главной сцене",
|
||||
"microphones": "Микрофоны",
|
||||
"moderator": "Модератор",
|
||||
"moderatorOptions": "Настройки модератора",
|
||||
|
||||
@@ -968,7 +968,7 @@
|
||||
"incomingMessage": "Messàgiu in intrada",
|
||||
"language": "Limba",
|
||||
"loggedIn": "Autenticatzione: {{name}}",
|
||||
"maxStageParticipants": "Nùmeru màssimu de partetzipantes chi podent èssere apicados a s'iscena printzipale (ISPERIMENTALE)",
|
||||
"maxStageParticipants": "Nùmeru màssimu de partetzipantes chi podent èssere apicados a s'iscena printzipale",
|
||||
"microphones": "Micròfonos",
|
||||
"moderator": "Moderadore",
|
||||
"more": "Àteru",
|
||||
|
||||
@@ -1110,7 +1110,7 @@
|
||||
"incomingMessage": "Mesazh ardhës",
|
||||
"language": "Gjuhë",
|
||||
"loggedIn": "I futur si {{name}}",
|
||||
"maxStageParticipants": "Numër maksimum pjesëmarrësish që mund të fiksohen te skena kryesore (EKSPERIMENTALe)",
|
||||
"maxStageParticipants": "Numër maksimum pjesëmarrësish që mund të fiksohen te skena kryesore",
|
||||
"microphones": "Mikrofona",
|
||||
"moderator": "Moderator",
|
||||
"moderatorOptions": "Mundësi moderatori",
|
||||
|
||||
@@ -995,7 +995,7 @@
|
||||
"incomingMessage": "Вхідне повідомлення",
|
||||
"language": "Мова",
|
||||
"loggedIn": "Увійшли як {{name}}",
|
||||
"maxStageParticipants": "Максимальна кількість учасників, яку можна закріпити на головній сцені (ТЕСТУВАННЯ)",
|
||||
"maxStageParticipants": "Максимальна кількість учасників, яку можна закріпити на головній сцені",
|
||||
"microphones": "Мікрофони",
|
||||
"moderator": "Модератор",
|
||||
"moderatorOptions": "Параметри модерації",
|
||||
|
||||
@@ -1081,7 +1081,7 @@
|
||||
"incomingMessage": "Tin nhắn đang gửi",
|
||||
"language": "Ngôn ngữ",
|
||||
"loggedIn": "Đã đăng nhập dưới tên {{name}}",
|
||||
"maxStageParticipants": "Số lượng người tham gia tối đa có thể được ghim vào sân khấu chính (THỬ NGHIỆM)",
|
||||
"maxStageParticipants": "Số lượng người tham gia tối đa có thể được ghim vào sân khấu chính",
|
||||
"microphones": "Micro",
|
||||
"moderator": "Quản trị viên",
|
||||
"moderatorOptions": "Tùy chọn quản trị viên",
|
||||
|
||||
@@ -1049,7 +1049,7 @@
|
||||
"incomingMessage": "新消息",
|
||||
"language": "语言",
|
||||
"loggedIn": "以{{name}}登录",
|
||||
"maxStageParticipants": "可以固定的最大参会者人数(实验性功能)",
|
||||
"maxStageParticipants": "可以固定的最大参会者人数",
|
||||
"microphones": "麦克风",
|
||||
"moderator": "主持人",
|
||||
"moderatorOptions": "主持人选项",
|
||||
|
||||
@@ -1066,7 +1066,7 @@
|
||||
"incomingMessage": "新訊息",
|
||||
"language": "語言",
|
||||
"loggedIn": "以{{name}}登入",
|
||||
"maxStageParticipants": "可被釘選的最大與會者人數(實驗性功能)",
|
||||
"maxStageParticipants": "可被釘選的最大與會者人數",
|
||||
"microphones": "麥克風",
|
||||
"moderator": "主持人",
|
||||
"moderatorOptions": "主持人選項",
|
||||
|
||||
@@ -122,7 +122,9 @@
|
||||
"nickname": {
|
||||
"popover": "Choose a nickname",
|
||||
"title": "Enter a nickname to use chat",
|
||||
"titleWithPolls": "Enter a nickname to use chat and polls"
|
||||
"titleWithCC": "Enter a nickname to use chat and closed captions",
|
||||
"titleWithPolls": "Enter a nickname to use chat and polls",
|
||||
"titleWithPollsAndCC": "Enter a nickname to use chat, polls and closed captions"
|
||||
},
|
||||
"noMessagesMessage": "There are no messages in the meeting yet. Start a conversation here!",
|
||||
"privateNotice": "Private message to {{recipient}}",
|
||||
@@ -131,10 +133,13 @@
|
||||
"systemDisplayName": "System",
|
||||
"tabs": {
|
||||
"chat": "Chat",
|
||||
"closedCaptions": "CC",
|
||||
"polls": "Polls"
|
||||
},
|
||||
"title": "Chat",
|
||||
"titleWithCC": "Chat and CC",
|
||||
"titleWithPolls": "Chat and Polls",
|
||||
"titleWithPollsAndCC": "Chat, Polls and CC",
|
||||
"you": "you"
|
||||
},
|
||||
"chromeExtensionBanner": {
|
||||
@@ -144,6 +149,10 @@
|
||||
"dontShowAgain": "Don’t show me this again",
|
||||
"installExtensionText": "Install the extension for Google Calendar and Office 365 integration"
|
||||
},
|
||||
"closedCaptionsTab": {
|
||||
"emptyState": "The closed captions content will be available once a moderator starts it",
|
||||
"startClosedCaptionsButton": "Start closed captions"
|
||||
},
|
||||
"connectingOverlay": {
|
||||
"joiningRoom": "Connecting you to your meeting…"
|
||||
},
|
||||
@@ -881,6 +890,7 @@
|
||||
"waitingLobby": "Waiting in lobby ({{count}})"
|
||||
},
|
||||
"search": "Search participants",
|
||||
"searchDescription": "Start typing to filter participants",
|
||||
"title": "Participants"
|
||||
},
|
||||
"passwordDigitsOnly": "Up to {{number}} digits",
|
||||
@@ -1119,7 +1129,7 @@
|
||||
"incomingMessage": "Incoming message",
|
||||
"language": "Language",
|
||||
"loggedIn": "Logged in as {{name}}",
|
||||
"maxStageParticipants": "Maximum number of participants who can be pinned to the main stage (EXPERIMENTAL)",
|
||||
"maxStageParticipants": "Maximum number of participants who can be pinned to the main stage",
|
||||
"microphones": "Microphones",
|
||||
"moderator": "Moderator",
|
||||
"moderatorOptions": "Moderator options",
|
||||
@@ -1138,6 +1148,7 @@
|
||||
"selectMic": "Microphone",
|
||||
"selfView": "Self view",
|
||||
"shortcuts": "Shortcuts",
|
||||
"showSubtitlesOnStage": "Show subtitles on stage",
|
||||
"speakers": "Speakers",
|
||||
"startAudioMuted": "Everyone starts muted",
|
||||
"startReactionsMuted": "Mute reaction sounds for everyone",
|
||||
@@ -1197,6 +1208,7 @@
|
||||
"neutral": "Neutral",
|
||||
"sad": "Sad",
|
||||
"search": "Search",
|
||||
"searchDescription": "Start typing to filter participants",
|
||||
"searchHint": "Search participants",
|
||||
"seconds": "{{count}}s",
|
||||
"speakerStats": "Participants Stats",
|
||||
@@ -1233,6 +1245,7 @@
|
||||
"closeChat": "Close chat",
|
||||
"closeMoreActions": "Close more actions menu",
|
||||
"closeParticipantsPane": "Close participants pane",
|
||||
"closedCaptions": "Closed captions",
|
||||
"collapse": "Collapse",
|
||||
"document": "Toggle shared document",
|
||||
"documentClose": "Close shared document",
|
||||
@@ -1323,6 +1336,7 @@
|
||||
"closeChat": "Close chat",
|
||||
"closeParticipantsPane": "Close participants pane",
|
||||
"closeReactionsMenu": "Close reactions menu",
|
||||
"closedCaptions": "Closed captions",
|
||||
"disableNoiseSuppression": "Disable extra noise suppression (BETA)",
|
||||
"disableReactionSounds": "You can disable reaction sounds for this meeting",
|
||||
"documentClose": "Close shared document",
|
||||
@@ -1415,13 +1429,16 @@
|
||||
"failed": "Transcribing failed",
|
||||
"labelTooltip": "This meeting is being transcribed.",
|
||||
"labelTooltipExtra": "In addition, a transcript will be available later.",
|
||||
"openClosedCaptions": "Open closed captions",
|
||||
"original": "Original",
|
||||
"sourceLanguageDesc": "Currently the meeting language is set to <b>{{sourceLanguage}}</b>. <br/> You can change it from ",
|
||||
"sourceLanguageHere": "here",
|
||||
"start": "Start showing subtitles",
|
||||
"stop": "Stop showing subtitles",
|
||||
"subtitles": "Subtitles",
|
||||
"subtitlesOff": "Off",
|
||||
"tr": "TR"
|
||||
"tr": "TR",
|
||||
"translateTo": "Translate to"
|
||||
},
|
||||
"unpinParticipant": "{{participantName}} - Unpin",
|
||||
"userMedia": {
|
||||
|
||||
991
package-lock.json
generated
991
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,6 @@
|
||||
"@jitsi/js-utils": "2.2.1",
|
||||
"@jitsi/logger": "2.0.2",
|
||||
"@jitsi/rnnoise-wasm": "0.2.1",
|
||||
"@jitsi/rtcstats": "9.5.1",
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
|
||||
"@microsoft/microsoft-graph-client": "3.0.1",
|
||||
"@mui/material": "5.12.1",
|
||||
@@ -68,7 +67,7 @@
|
||||
"js-md5": "0.6.1",
|
||||
"js-sha512": "0.8.0",
|
||||
"jwt-decode": "2.2.0",
|
||||
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1973.0.0+64dcc15c/lib-jitsi-meet.tgz",
|
||||
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1980.0.0+34a32e86/lib-jitsi-meet.tgz",
|
||||
"lodash-es": "4.17.21",
|
||||
"moment": "2.29.4",
|
||||
"moment-duration-format": "2.2.2",
|
||||
@@ -123,7 +122,6 @@
|
||||
"util": "0.12.1",
|
||||
"uuid": "8.3.2",
|
||||
"wasm-check": "2.0.1",
|
||||
"webm-duration-fix": "1.0.4",
|
||||
"windows-iana": "3.1.0",
|
||||
"zxcvbn": "4.4.2"
|
||||
},
|
||||
|
||||
@@ -72,11 +72,15 @@ export function getInitials(s?: string) {
|
||||
/**
|
||||
* Checks if the passed URL should be loaded with CORS.
|
||||
*
|
||||
* @param {string} url - The URL.
|
||||
* @param {string | Function} url - The URL (on mobile we use a specific Icon component for avatars).
|
||||
* @param {Array<string>} corsURLs - The URL pattern that matches a URL that needs to be handled with CORS.
|
||||
* @returns {void}
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isCORSAvatarURL(url: string, corsURLs: Array<string> = []): boolean {
|
||||
export function isCORSAvatarURL(url: string | Function, corsURLs: Array<string> = []): boolean {
|
||||
if (typeof url === 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return corsURLs.some(pattern => url.startsWith(pattern));
|
||||
}
|
||||
|
||||
|
||||
@@ -617,6 +617,7 @@ export interface IConfig {
|
||||
transcription?: {
|
||||
autoCaptionOnTranscribe?: boolean;
|
||||
autoTranscribeOnRecord?: boolean;
|
||||
disableClosedCaptions?: boolean;
|
||||
enabled?: boolean;
|
||||
preferredLanguage?: string;
|
||||
translationLanguages?: Array<string>;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* localFlipX: boolean,
|
||||
* micDeviceId: string,
|
||||
* serverURL: string,
|
||||
* showSubtitlesOnStage: boolean,
|
||||
* startAudioOnly: boolean,
|
||||
* startWithAudioMuted: boolean,
|
||||
* startWithVideoMuted: boolean,
|
||||
|
||||
@@ -29,6 +29,7 @@ const DEFAULT_STATE: ISettingsState = {
|
||||
micDeviceId: undefined,
|
||||
serverURL: undefined,
|
||||
hideShareAudioHelper: false,
|
||||
showSubtitlesOnStage: false,
|
||||
soundsIncomingMessage: true,
|
||||
soundsParticipantJoined: true,
|
||||
soundsParticipantKnocking: true,
|
||||
@@ -67,6 +68,7 @@ export interface ISettingsState {
|
||||
maxStageParticipants?: number;
|
||||
micDeviceId?: string | boolean;
|
||||
serverURL?: string;
|
||||
showSubtitlesOnStage?: boolean;
|
||||
soundsIncomingMessage?: boolean;
|
||||
soundsParticipantJoined?: boolean;
|
||||
soundsParticipantKnocking?: boolean;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
// Mapping between the token used and the color
|
||||
export const colorMap = {
|
||||
// ----- Surfaces -----
|
||||
@@ -119,8 +118,8 @@ export const colorMap = {
|
||||
|
||||
|
||||
export const font = {
|
||||
weightRegular: '400',
|
||||
weightSemiBold: '600'
|
||||
weightRegular: 400,
|
||||
weightSemiBold: 600
|
||||
};
|
||||
|
||||
export const shape = {
|
||||
@@ -130,7 +129,7 @@ export const shape = {
|
||||
};
|
||||
|
||||
export const spacing
|
||||
= [ 0, 4, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128 ];
|
||||
= [ '0rem', '0.25rem', '0.5rem', '1rem', '1.5rem', '2rem', '2.5rem', '3rem', '3.5rem', '4rem', '4.5rem', '5rem', '5.5rem', '6rem', '6.5rem', '7rem', '7.5rem', '8rem' ];
|
||||
|
||||
export const typography = {
|
||||
labelRegular: 'label01',
|
||||
|
||||
@@ -213,7 +213,7 @@ const ContextMenu = ({
|
||||
|
||||
if (offsetTop + height > offsetHeight + scrollTop && height > offsetTop) {
|
||||
// top offset and + padding + border
|
||||
container.style.maxHeight = `${offsetTop - ((spacing[2] * 2) + 2)}px`;
|
||||
container.style.maxHeight = `calc(${offsetTop}px - (${spacing[2]} * 2 + 2px))`;
|
||||
}
|
||||
|
||||
// get the height after style changes
|
||||
|
||||
29
react/features/base/ui/components/web/HiddenDescription.tsx
Normal file
29
react/features/base/ui/components/web/HiddenDescription.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
interface IHiddenDescriptionProps {
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const HiddenDescription: React.FC<IHiddenDescriptionProps> = ({ id, children }) => {
|
||||
const hiddenStyle: React.CSSProperties = {
|
||||
border: 0,
|
||||
clip: 'rect(0 0 0 0)',
|
||||
clipPath: 'inset(50%)',
|
||||
height: '1px',
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
whiteSpace: 'nowrap'
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
id = { id }
|
||||
style = { hiddenStyle }>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import Icon from '../../../icons/components/Icon';
|
||||
import { IconCloseCircle } from '../../../icons/svg';
|
||||
import { withPixelLineHeight } from '../../../styles/functions.web';
|
||||
import { IInputProps } from '../types';
|
||||
import { HiddenDescription } from './HiddenDescription';
|
||||
|
||||
interface IProps extends IInputProps {
|
||||
accessibilityLabel?: string;
|
||||
@@ -14,6 +15,7 @@ interface IProps extends IInputProps {
|
||||
autoFocus?: boolean;
|
||||
bottomLabel?: string;
|
||||
className?: string;
|
||||
hiddenDescription?: string; // Text that will be announced by screen readers but not displayed visually.
|
||||
iconClick?: () => void;
|
||||
|
||||
/**
|
||||
@@ -152,13 +154,14 @@ const useStyles = makeStyles()(theme => {
|
||||
|
||||
const Input = React.forwardRef<any, IProps>(({
|
||||
accessibilityLabel,
|
||||
autoComplete,
|
||||
autoComplete = 'off',
|
||||
autoFocus,
|
||||
bottomLabel,
|
||||
className,
|
||||
clearable = false,
|
||||
disabled,
|
||||
error,
|
||||
hiddenDescription,
|
||||
icon,
|
||||
iconClick,
|
||||
id,
|
||||
@@ -185,11 +188,22 @@ const Input = React.forwardRef<any, IProps>(({
|
||||
const { classes: styles, cx } = useStyles();
|
||||
const isMobile = isMobileBrowser();
|
||||
const showClearIcon = clearable && value !== '' && !disabled;
|
||||
const inputAutoCompleteOff = autoComplete === 'off' ? { 'data-1p-ignore': '' } : {};
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
onChange?.(e.target.value), []);
|
||||
|
||||
const clearInput = useCallback(() => onChange?.(''), []);
|
||||
const hiddenDescriptionId = `${id}-hidden-description`;
|
||||
let ariaDescribedById: string | undefined;
|
||||
|
||||
if (bottomLabel) {
|
||||
ariaDescribedById = `${id}-description`;
|
||||
} else if (hiddenDescription) {
|
||||
ariaDescribedById = hiddenDescriptionId;
|
||||
} else {
|
||||
ariaDescribedById = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { cx(styles.inputContainer, className) }>
|
||||
@@ -207,6 +221,7 @@ const Input = React.forwardRef<any, IProps>(({
|
||||
src = { icon } />}
|
||||
{textarea ? (
|
||||
<TextareaAutosize
|
||||
aria-describedby = { ariaDescribedById }
|
||||
aria-label = { accessibilityLabel }
|
||||
autoComplete = { autoComplete }
|
||||
autoFocus = { autoFocus }
|
||||
@@ -227,7 +242,7 @@ const Input = React.forwardRef<any, IProps>(({
|
||||
value = { value } />
|
||||
) : (
|
||||
<input
|
||||
aria-describedby = { bottomLabel ? `${id}-description` : undefined }
|
||||
aria-describedby = { ariaDescribedById }
|
||||
aria-label = { accessibilityLabel }
|
||||
autoComplete = { autoComplete }
|
||||
autoFocus = { autoFocus }
|
||||
@@ -236,6 +251,7 @@ const Input = React.forwardRef<any, IProps>(({
|
||||
data-testid = { testId }
|
||||
disabled = { disabled }
|
||||
id = { id }
|
||||
{ ...inputAutoCompleteOff }
|
||||
{ ...(mode ? { inputmode: mode } : {}) }
|
||||
{ ...(type === 'number' ? { max: maxValue } : {}) }
|
||||
maxLength = { maxLength }
|
||||
@@ -266,6 +282,7 @@ const Input = React.forwardRef<any, IProps>(({
|
||||
{bottomLabel}
|
||||
</span>
|
||||
)}
|
||||
{!bottomLabel && hiddenDescription && <HiddenDescription id = { hiddenDescriptionId }>{ hiddenDescription }</HiddenDescription>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,47 @@ import { DefaultTheme } from 'react-native-paper';
|
||||
|
||||
import { createColorTokens } from './utils';
|
||||
|
||||
// Base font size in pixels (standard is 16px = 1rem)
|
||||
const BASE_FONT_SIZE = 16;
|
||||
|
||||
/**
|
||||
* Converts rem to pixels.
|
||||
*
|
||||
* @param {string} remValue - The value in rem units (e.g. '0.875rem').
|
||||
* @returns {number}
|
||||
*/
|
||||
function remToPixels(remValue: string): number {
|
||||
const numericValue = parseFloat(remValue.replace('rem', ''));
|
||||
|
||||
return Math.round(numericValue * BASE_FONT_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts all rem to pixels in an object.
|
||||
*
|
||||
* @param {Object} obj - The object to convert rem values in.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function convertRemValues(obj: any): any {
|
||||
const converted: { [key: string]: any; } = {};
|
||||
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
Object.entries(obj).forEach(([ key, value ]) => {
|
||||
if (typeof value === 'string' && value.includes('rem')) {
|
||||
converted[key] = remToPixels(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
converted[key] = convertRemValues(value);
|
||||
} else {
|
||||
converted[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a React Native Paper theme based on local UI tokens.
|
||||
*
|
||||
@@ -13,10 +54,10 @@ export function createNativeTheme({ font, colorMap, shape, spacing, typography }
|
||||
...DefaultTheme,
|
||||
palette: createColorTokens(colorMap),
|
||||
shape,
|
||||
spacing,
|
||||
spacing: spacing.map(remToPixels),
|
||||
typography: {
|
||||
font,
|
||||
...typography
|
||||
...convertRemValues(typography)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ThemeProps {
|
||||
colorMap: Object;
|
||||
font: Object;
|
||||
shape: Object;
|
||||
spacing: Array<number>;
|
||||
spacing: Array<number | string>;
|
||||
typography: Object;
|
||||
}
|
||||
|
||||
|
||||
79
react/features/base/util/messageGrouping.ts
Normal file
79
react/features/base/util/messageGrouping.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Interface representing a message that can be grouped.
|
||||
* Used by both chat messages and subtitles.
|
||||
*/
|
||||
export interface IGroupableMessage {
|
||||
|
||||
/**
|
||||
* The ID of the participant who sent the message.
|
||||
*/
|
||||
participantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface representing a group of messages from the same sender.
|
||||
*
|
||||
* @template T - The type of messages in the group, must extend IGroupableMessage.
|
||||
*/
|
||||
export interface IMessageGroup<T extends IGroupableMessage> {
|
||||
|
||||
/**
|
||||
* Array of messages in this group.
|
||||
*/
|
||||
messages: T[];
|
||||
|
||||
/**
|
||||
* The ID of the participant who sent all messages in this group.
|
||||
*/
|
||||
senderId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups an array of messages by sender.
|
||||
*
|
||||
* @template T - The type of messages to group, must extend IGroupableMessage.
|
||||
* @param {T[]} messages - The array of messages to group.
|
||||
* @returns {IMessageGroup<T>[]} - An array of message groups, where each group contains messages from the same sender.
|
||||
* @example
|
||||
* const messages = [
|
||||
* { participantId: "user1", timestamp: 1000 },
|
||||
* { participantId: "user1", timestamp: 2000 },
|
||||
* { participantId: "user2", timestamp: 3000 }
|
||||
* ];
|
||||
* const groups = groupMessagesBySender(messages);
|
||||
* // Returns:
|
||||
* // [
|
||||
* // {
|
||||
* // senderId: "user1",
|
||||
* // messages: [
|
||||
* // { participantId: "user1", timestamp: 1000 },
|
||||
* // { participantId: "user1", timestamp: 2000 }
|
||||
* // ]
|
||||
* // },
|
||||
* // { senderId: "user2", messages: [{ participantId: "user2", timestamp: 3000 }] }
|
||||
* // ]
|
||||
*/
|
||||
export function groupMessagesBySender<T extends IGroupableMessage>(
|
||||
messages: T[]
|
||||
): IMessageGroup<T>[] {
|
||||
if (!messages?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groups: IMessageGroup<T>[] = [];
|
||||
let currentGroup: IMessageGroup<T> | null = null;
|
||||
|
||||
for (const message of messages) {
|
||||
if (!currentGroup || currentGroup.senderId !== message.participantId) {
|
||||
currentGroup = {
|
||||
messages: [ message ],
|
||||
senderId: message.participantId
|
||||
};
|
||||
groups.push(currentGroup);
|
||||
} else {
|
||||
currentGroup.messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
9
react/features/base/util/spot.ts
Normal file
9
react/features/base/util/spot.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
/**
|
||||
* Checks if Jitsi Meet is running on Spot TV.
|
||||
*
|
||||
* @returns {boolean} Whether or not Jitsi Meet is running on Spot TV.
|
||||
*/
|
||||
export function isSpotTV(): boolean {
|
||||
return navigator.userAgent.includes('SpotElectron/');
|
||||
}
|
||||
@@ -98,14 +98,14 @@ export const SEND_REACTION = 'SEND_REACTION';
|
||||
export const SET_PRIVATE_MESSAGE_RECIPIENT = 'SET_PRIVATE_MESSAGE_RECIPIENT';
|
||||
|
||||
/**
|
||||
* The type of action which signals the update a _isPollsTabFocused.
|
||||
* The type of action which signals setting the focused tab.
|
||||
*
|
||||
* {
|
||||
* isPollsTabFocused: boolean,
|
||||
* type: SET_PRIVATE_MESSAGE_RECIPIENT
|
||||
* type: SET_FOCUSED_TAB,
|
||||
* tabId: string
|
||||
* }
|
||||
*/
|
||||
export const SET_IS_POLL_TAB_FOCUSED = 'SET_IS_POLL_TAB_FOCUSED';
|
||||
export const SET_FOCUSED_TAB = 'SET_FOCUSED_TAB';
|
||||
|
||||
/**
|
||||
* The type of action which sets the current recipient for lobby messages.
|
||||
|
||||
@@ -10,14 +10,16 @@ import {
|
||||
CLEAR_MESSAGES,
|
||||
CLOSE_CHAT,
|
||||
EDIT_MESSAGE,
|
||||
OPEN_CHAT,
|
||||
REMOVE_LOBBY_CHAT_PARTICIPANT,
|
||||
SEND_MESSAGE,
|
||||
SEND_REACTION,
|
||||
SET_IS_POLL_TAB_FOCUSED,
|
||||
SET_FOCUSED_TAB,
|
||||
SET_LOBBY_CHAT_ACTIVE_STATE,
|
||||
SET_LOBBY_CHAT_RECIPIENT,
|
||||
SET_PRIVATE_MESSAGE_RECIPIENT
|
||||
} from './actionTypes';
|
||||
import { ChatTabs } from './constants';
|
||||
|
||||
/**
|
||||
* Adds a chat message to the collection of messages.
|
||||
@@ -169,18 +171,36 @@ export function setPrivateMessageRecipient(participant?: Object) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of _isPollsTabFocused.
|
||||
* Set the value of the currently focused tab.
|
||||
*
|
||||
* @param {boolean} isPollsTabFocused - The new value for _isPollsTabFocused.
|
||||
* @returns {Function}
|
||||
* @param {string} tabId - The id of the currently focused tab.
|
||||
* @returns {{
|
||||
* type: SET_FOCUSED_TAB,
|
||||
* tabId: string
|
||||
* }}
|
||||
*/
|
||||
export function setIsPollsTabFocused(isPollsTabFocused: boolean) {
|
||||
export function setFocusedTab(tabId: ChatTabs) {
|
||||
return {
|
||||
isPollsTabFocused,
|
||||
type: SET_IS_POLL_TAB_FOCUSED
|
||||
type: SET_FOCUSED_TAB,
|
||||
tabId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the chat panel with CC tab active.
|
||||
*
|
||||
* @returns {Object} The redux action.
|
||||
*/
|
||||
export function openCCPanel() {
|
||||
return async (dispatch: IStore['dispatch']) => {
|
||||
dispatch(setFocusedTab(ChatTabs.CLOSED_CAPTIONS));
|
||||
dispatch({
|
||||
type: OPEN_CHAT
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initiates the sending of messages between a moderator and a lobby attendee.
|
||||
*
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import React from 'react';
|
||||
import React, { Component } from 'react';
|
||||
import { FlatList, Text, TextStyle, View, ViewStyle } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IMessageGroup, groupMessagesBySender } from '../../../base/util/messageGrouping';
|
||||
import { IMessage } from '../../types';
|
||||
import AbstractMessageContainer, { IProps as AbstractProps } from '../AbstractMessageContainer';
|
||||
|
||||
import ChatMessageGroup from './ChatMessageGroup';
|
||||
import styles from './styles';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
/**
|
||||
* Function to be used to translate i18n labels.
|
||||
*/
|
||||
interface IProps {
|
||||
messages: IMessage[];
|
||||
t: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a container to render all the chat messages in a conference.
|
||||
*/
|
||||
class MessageContainer extends AbstractMessageContainer<IProps, any> {
|
||||
class MessageContainer extends Component<IProps, any> {
|
||||
|
||||
static defaultProps = {
|
||||
messages: [] as IMessage[]
|
||||
};
|
||||
|
||||
/**
|
||||
* Instantiates a new instance of the component.
|
||||
*
|
||||
@@ -32,6 +34,7 @@ class MessageContainer extends AbstractMessageContainer<IProps, any> {
|
||||
this._keyExtractor = this._keyExtractor.bind(this);
|
||||
this._renderListEmptyComponent = this._renderListEmptyComponent.bind(this);
|
||||
this._renderMessageGroup = this._renderMessageGroup.bind(this);
|
||||
this._getMessagesGroupedBySender = this._getMessagesGroupedBySender.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,9 +97,21 @@ class MessageContainer extends AbstractMessageContainer<IProps, any> {
|
||||
* @param {Array<Object>} messages - The chat message to render.
|
||||
* @returns {React$Element<*>}
|
||||
*/
|
||||
_renderMessageGroup({ item: messages }: { item: IMessage[]; }) {
|
||||
_renderMessageGroup({ item: group }: { item: IMessageGroup<IMessage>; }) {
|
||||
const { messages } = group;
|
||||
|
||||
return <ChatMessageGroup messages = { messages } />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of message groups, where each group is an array of messages
|
||||
* grouped by the sender.
|
||||
*
|
||||
* @returns {Array<Array<Object>>}
|
||||
*/
|
||||
_getMessagesGroupedBySender() {
|
||||
return groupMessagesBySender(this.props.messages);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect()(MessageContainer));
|
||||
|
||||
@@ -9,12 +9,14 @@ import { withPixelLineHeight } from '../../../base/styles/functions.web';
|
||||
import Tabs from '../../../base/ui/components/web/Tabs';
|
||||
import { arePollsDisabled } from '../../../conference/functions.any';
|
||||
import PollsPane from '../../../polls/components/web/PollsPane';
|
||||
import { sendMessage, setIsPollsTabFocused, toggleChat } from '../../actions.web';
|
||||
import { CHAT_SIZE, CHAT_TABS, SMALL_WIDTH_THRESHOLD } from '../../constants';
|
||||
import { isCCTabEnabled } from '../../../subtitles/functions.any';
|
||||
import { sendMessage, setFocusedTab, toggleChat } from '../../actions.web';
|
||||
import { CHAT_SIZE, ChatTabs, SMALL_WIDTH_THRESHOLD } from '../../constants';
|
||||
import { IChatProps as AbstractProps } from '../../types';
|
||||
|
||||
import ChatHeader from './ChatHeader';
|
||||
import ChatInput from './ChatInput';
|
||||
import ClosedCaptionsTab from './ClosedCaptionsTab';
|
||||
import DisplayNameForm from './DisplayNameForm';
|
||||
import KeyboardAvoider from './KeyboardAvoider';
|
||||
import MessageContainer from './MessageContainer';
|
||||
@@ -22,6 +24,16 @@ import MessageRecipient from './MessageRecipient';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
/**
|
||||
* The currently focused tab.
|
||||
*/
|
||||
_focusedTab: ChatTabs;
|
||||
|
||||
/**
|
||||
* True if the CC tab is enabled and false otherwise.
|
||||
*/
|
||||
_isCCTabEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether the chat is opened in a modal or not (computed based on window width).
|
||||
*/
|
||||
@@ -37,11 +49,6 @@ interface IProps extends AbstractProps {
|
||||
*/
|
||||
_isPollsEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether the poll tab is focused or not.
|
||||
*/
|
||||
_isPollsTabFocused: boolean;
|
||||
|
||||
/**
|
||||
* Number of unread poll messages.
|
||||
*/
|
||||
@@ -147,7 +154,8 @@ const Chat = ({
|
||||
_isModal,
|
||||
_isOpen,
|
||||
_isPollsEnabled,
|
||||
_isPollsTabFocused,
|
||||
_isCCTabEnabled,
|
||||
_focusedTab,
|
||||
_messages,
|
||||
_nbUnreadMessages,
|
||||
_nbUnreadPolls,
|
||||
@@ -203,8 +211,8 @@ const Chat = ({
|
||||
* @returns {void}
|
||||
*/
|
||||
const onChangeTab = useCallback((id: string) => {
|
||||
dispatch(setIsPollsTabFocused(id !== CHAT_TABS.CHAT));
|
||||
}, []);
|
||||
dispatch(setFocusedTab(id as ChatTabs));
|
||||
}, [ dispatch ]);
|
||||
|
||||
/**
|
||||
* Returns a React Element for showing chat messages and a form to send new
|
||||
@@ -216,15 +224,15 @@ const Chat = ({
|
||||
function renderChat() {
|
||||
return (
|
||||
<>
|
||||
{_isPollsEnabled && renderTabs()}
|
||||
{renderTabs()}
|
||||
<div
|
||||
aria-labelledby = { CHAT_TABS.CHAT }
|
||||
aria-labelledby = { ChatTabs.CHAT }
|
||||
className = { cx(
|
||||
classes.chatPanel,
|
||||
!_isPollsEnabled && classes.chatPanelNoTabs,
|
||||
_isPollsTabFocused && 'hide'
|
||||
!_isPollsEnabled && !_isCCTabEnabled && classes.chatPanelNoTabs,
|
||||
_focusedTab !== ChatTabs.CHAT && 'hide'
|
||||
) }
|
||||
id = { `${CHAT_TABS.CHAT}-panel` }
|
||||
id = { `${ChatTabs.CHAT}-panel` }
|
||||
role = 'tabpanel'
|
||||
tabIndex = { 0 }>
|
||||
<MessageContainer
|
||||
@@ -233,49 +241,76 @@ const Chat = ({
|
||||
<ChatInput
|
||||
onSend = { onSendMessage } />
|
||||
</div>
|
||||
{_isPollsEnabled && (
|
||||
{ _isPollsEnabled && (
|
||||
<>
|
||||
<div
|
||||
aria-labelledby = { CHAT_TABS.POLLS }
|
||||
className = { cx(classes.pollsPanel, !_isPollsTabFocused && 'hide') }
|
||||
id = { `${CHAT_TABS.POLLS}-panel` }
|
||||
aria-labelledby = { ChatTabs.POLLS }
|
||||
className = { cx(classes.pollsPanel, _focusedTab !== ChatTabs.POLLS && 'hide') }
|
||||
id = { `${ChatTabs.POLLS}-panel` }
|
||||
role = 'tabpanel'
|
||||
tabIndex = { 0 }>
|
||||
tabIndex = { 1 }>
|
||||
<PollsPane />
|
||||
</div>
|
||||
<KeyboardAvoider />
|
||||
</>
|
||||
)}
|
||||
{ _isCCTabEnabled && <div
|
||||
aria-labelledby = { ChatTabs.CLOSED_CAPTIONS }
|
||||
className = { cx(classes.chatPanel, _focusedTab !== ChatTabs.CLOSED_CAPTIONS && 'hide') }
|
||||
id = { `${ChatTabs.CLOSED_CAPTIONS}-panel` }
|
||||
role = 'tabpanel'
|
||||
tabIndex = { 2 }>
|
||||
<ClosedCaptionsTab />
|
||||
</div> }
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a React Element showing the Chat and Polls tab.
|
||||
* Returns a React Element showing the Chat, Polls and Subtitles tabs.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
function renderTabs() {
|
||||
const tabs = [
|
||||
{
|
||||
accessibilityLabel: t('chat.tabs.chat'),
|
||||
countBadge:
|
||||
_focusedTab !== ChatTabs.CHAT && _nbUnreadMessages > 0 ? _nbUnreadMessages : undefined,
|
||||
id: ChatTabs.CHAT,
|
||||
controlsId: `${ChatTabs.CHAT}-panel`,
|
||||
label: t('chat.tabs.chat')
|
||||
}
|
||||
];
|
||||
|
||||
if (_isPollsEnabled) {
|
||||
tabs.push({
|
||||
accessibilityLabel: t('chat.tabs.polls'),
|
||||
countBadge: _focusedTab !== ChatTabs.POLLS && _nbUnreadPolls > 0 ? _nbUnreadPolls : undefined,
|
||||
id: ChatTabs.POLLS,
|
||||
controlsId: `${ChatTabs.POLLS}-panel`,
|
||||
label: t('chat.tabs.polls')
|
||||
});
|
||||
}
|
||||
|
||||
if (_isCCTabEnabled) {
|
||||
tabs.push({
|
||||
accessibilityLabel: t('chat.tabs.closedCaptions'),
|
||||
countBadge: undefined,
|
||||
id: ChatTabs.CLOSED_CAPTIONS,
|
||||
controlsId: `${ChatTabs.CLOSED_CAPTIONS}-panel`,
|
||||
label: t('chat.tabs.closedCaptions')
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
accessibilityLabel = { t(_isPollsEnabled ? 'chat.titleWithPolls' : 'chat.title') }
|
||||
onChange = { onChangeTab }
|
||||
selected = { _isPollsTabFocused ? CHAT_TABS.POLLS : CHAT_TABS.CHAT }
|
||||
tabs = { [ {
|
||||
accessibilityLabel: t('chat.tabs.chat'),
|
||||
countBadge: _isPollsTabFocused && _nbUnreadMessages > 0 ? _nbUnreadMessages : undefined,
|
||||
id: CHAT_TABS.CHAT,
|
||||
controlsId: `${CHAT_TABS.CHAT}-panel`,
|
||||
label: t('chat.tabs.chat')
|
||||
}, {
|
||||
accessibilityLabel: t('chat.tabs.polls'),
|
||||
countBadge: !_isPollsTabFocused && _nbUnreadPolls > 0 ? _nbUnreadPolls : undefined,
|
||||
id: CHAT_TABS.POLLS,
|
||||
controlsId: `${CHAT_TABS.POLLS}-panel`,
|
||||
label: t('chat.tabs.polls')
|
||||
}
|
||||
] } />
|
||||
selected = { _focusedTab }
|
||||
tabs = { tabs } />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -286,10 +321,13 @@ const Chat = ({
|
||||
onKeyDown = { onEscClick } >
|
||||
<ChatHeader
|
||||
className = { cx('chat-header', classes.chatHeader) }
|
||||
isCCTabEnabled = { _isCCTabEnabled }
|
||||
isPollsEnabled = { _isPollsEnabled }
|
||||
onCancel = { onToggleChat } />
|
||||
{_showNamePrompt
|
||||
? <DisplayNameForm isPollsEnabled = { _isPollsEnabled } />
|
||||
? <DisplayNameForm
|
||||
isCCTabEnabled = { _isCCTabEnabled }
|
||||
isPollsEnabled = { _isPollsEnabled } />
|
||||
: renderChat()}
|
||||
</div> : null
|
||||
);
|
||||
@@ -306,7 +344,8 @@ const Chat = ({
|
||||
* _isModal: boolean,
|
||||
* _isOpen: boolean,
|
||||
* _isPollsEnabled: boolean,
|
||||
* _isPollsTabFocused: boolean,
|
||||
* _isCCTabEnabled: boolean,
|
||||
* _focusedTab: string,
|
||||
* _messages: Array<Object>,
|
||||
* _nbUnreadMessages: number,
|
||||
* _nbUnreadPolls: number,
|
||||
@@ -314,7 +353,7 @@ const Chat = ({
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState, _ownProps: any) {
|
||||
const { isOpen, isPollsTabFocused, messages, nbUnreadMessages } = state['features/chat'];
|
||||
const { isOpen, focusedTab, messages, nbUnreadMessages } = state['features/chat'];
|
||||
const { nbUnreadPolls } = state['features/polls'];
|
||||
const _localParticipant = getLocalParticipant(state);
|
||||
|
||||
@@ -322,7 +361,8 @@ function _mapStateToProps(state: IReduxState, _ownProps: any) {
|
||||
_isModal: window.innerWidth <= SMALL_WIDTH_THRESHOLD,
|
||||
_isOpen: isOpen,
|
||||
_isPollsEnabled: !arePollsDisabled(state),
|
||||
_isPollsTabFocused: isPollsTabFocused,
|
||||
_isCCTabEnabled: isCCTabEnabled(state),
|
||||
_focusedTab: focusedTab,
|
||||
_messages: messages,
|
||||
_nbUnreadMessages: nbUnreadMessages,
|
||||
_nbUnreadPolls: nbUnreadPolls,
|
||||
|
||||
@@ -13,6 +13,11 @@ interface IProps {
|
||||
*/
|
||||
className: string;
|
||||
|
||||
/**
|
||||
* Whether CC tab is enabled or not.
|
||||
*/
|
||||
isCCTabEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether the polls feature is enabled or not.
|
||||
*/
|
||||
@@ -29,7 +34,7 @@ interface IProps {
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
function ChatHeader({ className, isPollsEnabled }: IProps) {
|
||||
function ChatHeader({ className, isPollsEnabled, isCCTabEnabled }: IProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -44,13 +49,23 @@ function ChatHeader({ className, isPollsEnabled }: IProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
let title = 'chat.title';
|
||||
|
||||
if (isCCTabEnabled && isPollsEnabled) {
|
||||
title = 'chat.titleWithPollsAndCC';
|
||||
} else if (isCCTabEnabled) {
|
||||
title = 'chat.titleWithCC';
|
||||
} else if (isPollsEnabled) {
|
||||
title = 'chat.titleWithPolls';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { className || 'chat-dialog-header' }>
|
||||
<span
|
||||
aria-level = { 1 }
|
||||
role = 'heading'>
|
||||
{ t(isPollsEnabled ? 'chat.titleWithPolls' : 'chat.title') }
|
||||
{ t(title) }
|
||||
</span>
|
||||
<Icon
|
||||
ariaLabel = { t('toolbar.closeChat') }
|
||||
|
||||
178
react/features/chat/components/web/ClosedCaptionsTab.tsx
Normal file
178
react/features/chat/components/web/ClosedCaptionsTab.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconSubtitles } from '../../../base/icons/svg';
|
||||
import { withPixelLineHeight } from '../../../base/styles/functions.web';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import { groupMessagesBySender } from '../../../base/util/messageGrouping';
|
||||
import { setRequestingSubtitles } from '../../../subtitles/actions.any';
|
||||
import LanguageSelector from '../../../subtitles/components/web/LanguageSelector';
|
||||
import { canStartSubtitles } from '../../../subtitles/functions.any';
|
||||
import { ISubtitle } from '../../../subtitles/types';
|
||||
import { isTranscribing } from '../../../transcribing/functions';
|
||||
|
||||
import { SubtitlesMessagesContainer } from './SubtitlesMessagesContainer';
|
||||
import { IReduxState } from '../../../app/types';
|
||||
|
||||
/**
|
||||
* The styles for the ClosedCaptionsTab component.
|
||||
*/
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
subtitlesList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
padding: '16px',
|
||||
flex: 1,
|
||||
boxSizing: 'border-box',
|
||||
color: theme.palette.text01
|
||||
},
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
messagesContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
emptyContent: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
padding: '16px',
|
||||
boxSizing: 'border-box',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
color: theme.palette.text01,
|
||||
textAlign: 'center'
|
||||
},
|
||||
emptyIcon: {
|
||||
width: '100px',
|
||||
padding: '16px',
|
||||
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: 'auto'
|
||||
}
|
||||
},
|
||||
emptyState: {
|
||||
...withPixelLineHeight(theme.typography.bodyLongBold),
|
||||
color: theme.palette.text02
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that displays the subtitles history in a scrollable list.
|
||||
*
|
||||
* @returns {JSX.Element} - The ClosedCaptionsTab component.
|
||||
*/
|
||||
export default function ClosedCaptionsTab() {
|
||||
const { classes, theme } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const subtitles = useSelector((state: IReduxState) => state['features/subtitles'].subtitlesHistory);
|
||||
const language = useSelector((state: IReduxState) => state['features/subtitles']._language);
|
||||
const selectedLanguage = language?.replace('translation-languages:', '');
|
||||
const _isTranscribing = useSelector(isTranscribing);
|
||||
const _canStartSubtitles = useSelector(canStartSubtitles);
|
||||
const [ isButtonPressed, setButtonPressed ] = useState(false);
|
||||
|
||||
const filteredSubtitles = useMemo(() => {
|
||||
// First, create a map of transcription messages by message ID
|
||||
const transcriptionMessages = new Map(
|
||||
subtitles
|
||||
.filter(s => s.isTranscription)
|
||||
.map(s => [ s.id, s ])
|
||||
);
|
||||
|
||||
if (!selectedLanguage) {
|
||||
// When no language is selected, show all original transcriptions
|
||||
return Array.from(transcriptionMessages.values());
|
||||
}
|
||||
|
||||
// Then, create a map of translation messages by message ID
|
||||
const translationMessages = new Map(
|
||||
subtitles
|
||||
.filter(s => !s.isTranscription && s.language === selectedLanguage)
|
||||
.map(s => [ s.id, s ])
|
||||
);
|
||||
|
||||
// When a language is selected, for each transcription message:
|
||||
// 1. Use its translation if available
|
||||
// 2. Fall back to the original transcription if no translation exists
|
||||
return Array.from(transcriptionMessages.values())
|
||||
.filter((m: ISubtitle) => !m.interim)
|
||||
.map(m => translationMessages.get(m.id) ?? m);
|
||||
}, [ subtitles, selectedLanguage ]);
|
||||
|
||||
const groupedSubtitles = useMemo(() =>
|
||||
groupMessagesBySender(filteredSubtitles), [ filteredSubtitles ]);
|
||||
|
||||
const startClosedCaptions = useCallback(() => {
|
||||
if (isButtonPressed) {
|
||||
return;
|
||||
}
|
||||
dispatch(setRequestingSubtitles(true, false, null));
|
||||
setButtonPressed(true);
|
||||
}, [ dispatch, isButtonPressed, setButtonPressed ]);
|
||||
|
||||
if (!_isTranscribing) {
|
||||
if (_canStartSubtitles) {
|
||||
return (
|
||||
<div className = { classes.emptyContent }>
|
||||
<Button
|
||||
accessibilityLabel = 'Start Closed Captions'
|
||||
appearance = 'primary'
|
||||
disabled = { isButtonPressed }
|
||||
labelKey = 'closedCaptionsTab.startClosedCaptionsButton'
|
||||
onClick = { startClosedCaptions }
|
||||
size = 'large'
|
||||
type = 'primary' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isButtonPressed) {
|
||||
setButtonPressed(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { classes.emptyContent }>
|
||||
<Icon
|
||||
className = { classes.emptyIcon }
|
||||
color = { theme.palette.icon03 }
|
||||
src = { IconSubtitles } />
|
||||
<span className = { classes.emptyState }>
|
||||
{ t('closedCaptionsTab.emptyState') }
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isButtonPressed) {
|
||||
setButtonPressed(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<LanguageSelector />
|
||||
<div className = { classes.messagesContainer }>
|
||||
<SubtitlesMessagesContainer
|
||||
groups = { groupedSubtitles }
|
||||
messages = { filteredSubtitles } />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,11 @@ interface IProps extends WithTranslation {
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Whether CC tab is enabled or not.
|
||||
*/
|
||||
isCCTabEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether the polls feature is enabled or not.
|
||||
*/
|
||||
@@ -69,16 +74,26 @@ class DisplayNameForm extends Component<IProps, IState> {
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const { isPollsEnabled, t } = this.props;
|
||||
const { isCCTabEnabled, isPollsEnabled, t } = this.props;
|
||||
|
||||
let title = 'chat.nickname.title';
|
||||
|
||||
if (isCCTabEnabled && isPollsEnabled) {
|
||||
title = 'chat.nickname.titleWithPollsAndCC';
|
||||
} else if (isCCTabEnabled) {
|
||||
title = 'chat.nickname.titleWithCC';
|
||||
} else if (isPollsEnabled) {
|
||||
title = 'chat.nickname.titleWithPolls';
|
||||
}
|
||||
|
||||
return (
|
||||
<div id = 'nickname'>
|
||||
<form onSubmit = { this._onSubmit }>
|
||||
<Input
|
||||
accessibilityLabel = { t('chat.nickname.title') }
|
||||
accessibilityLabel = { t(title) }
|
||||
autoFocus = { true }
|
||||
id = 'nickinput'
|
||||
label = { t(isPollsEnabled ? 'chat.nickname.titleWithPolls' : 'chat.nickname.title') }
|
||||
label = { t(title) }
|
||||
name = 'name'
|
||||
onChange = { this._onDisplayNameChange }
|
||||
placeholder = { t('chat.nickname.popover') }
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { throttle } from 'lodash-es';
|
||||
import React, { RefObject } from 'react';
|
||||
import React, { Component, RefObject } from 'react';
|
||||
import { scrollIntoView } from 'seamless-scroll-polyfill';
|
||||
|
||||
import { groupMessagesBySender } from '../../../base/util/messageGrouping';
|
||||
import { MESSAGE_TYPE_LOCAL, MESSAGE_TYPE_REMOTE } from '../../constants';
|
||||
import AbstractMessageContainer, { IProps } from '../AbstractMessageContainer';
|
||||
import { IMessage } from '../../types';
|
||||
|
||||
|
||||
import ChatMessageGroup from './ChatMessageGroup';
|
||||
import NewMessagesButton from './NewMessagesButton';
|
||||
|
||||
interface IProps {
|
||||
messages: IMessage[];
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
@@ -29,9 +35,9 @@ interface IState {
|
||||
/**
|
||||
* Displays all received chat messages, grouped by sender.
|
||||
*
|
||||
* @augments AbstractMessageContainer
|
||||
* @augments Component
|
||||
*/
|
||||
export default class MessageContainer extends AbstractMessageContainer<IProps, IState> {
|
||||
export default class MessageContainer extends Component<IProps, IState> {
|
||||
/**
|
||||
* Component state used to decide when the hasNewMessages button to appear
|
||||
* and where to scroll when click on hasNewMessages button.
|
||||
@@ -59,6 +65,10 @@ export default class MessageContainer extends AbstractMessageContainer<IProps, I
|
||||
*/
|
||||
_bottomListObserver: IntersectionObserver;
|
||||
|
||||
static defaultProps = {
|
||||
messages: [] as IMessage[]
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes a new {@code MessageContainer} instance.
|
||||
*
|
||||
@@ -86,14 +96,15 @@ export default class MessageContainer extends AbstractMessageContainer<IProps, I
|
||||
*/
|
||||
override render() {
|
||||
const groupedMessages = this._getMessagesGroupedBySender();
|
||||
const messages = groupedMessages.map((group, index) => {
|
||||
const messageType = group[0]?.messageType;
|
||||
const content = groupedMessages.map((group, index) => {
|
||||
const { messages } = group;
|
||||
const messageType = messages[0]?.messageType;
|
||||
|
||||
return (
|
||||
<ChatMessageGroup
|
||||
className = { messageType || MESSAGE_TYPE_REMOTE }
|
||||
key = { index }
|
||||
messages = { group } />
|
||||
messages = { messages } />
|
||||
);
|
||||
});
|
||||
|
||||
@@ -106,7 +117,7 @@ export default class MessageContainer extends AbstractMessageContainer<IProps, I
|
||||
ref = { this._messageListRef }
|
||||
role = 'log'
|
||||
tabIndex = { 0 }>
|
||||
{ messages }
|
||||
{ content }
|
||||
|
||||
{ !this.state.isScrolledToBottom && this.state.hasNewMessages
|
||||
&& <NewMessagesButton
|
||||
@@ -313,4 +324,14 @@ export default class MessageContainer extends AbstractMessageContainer<IProps, I
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of message groups, where each group is an array of messages
|
||||
* grouped by the sender.
|
||||
*
|
||||
* @returns {Array<Array<Object>>}
|
||||
*/
|
||||
_getMessagesGroupedBySender() {
|
||||
return groupMessagesBySender(this.props.messages);
|
||||
}
|
||||
}
|
||||
|
||||
97
react/features/chat/components/web/SubtitleMessage.tsx
Normal file
97
react/features/chat/components/web/SubtitleMessage.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { getParticipantDisplayName } from '../../../base/participants/functions';
|
||||
import { withPixelLineHeight } from '../../../base/styles/functions.web';
|
||||
import { ISubtitle } from '../../../subtitles/types';
|
||||
|
||||
/**
|
||||
* Props for the SubtitleMessage component.
|
||||
*/
|
||||
interface IProps extends ISubtitle {
|
||||
|
||||
/**
|
||||
* Whether to show the display name of the participant.
|
||||
*/
|
||||
showDisplayName: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The styles for the SubtitleMessage component.
|
||||
*/
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
messageContainer: {
|
||||
backgroundColor: theme.palette.ui02,
|
||||
borderRadius: '4px 12px 12px 12px',
|
||||
padding: '12px',
|
||||
maxWidth: '100%',
|
||||
marginTop: '4px',
|
||||
boxSizing: 'border-box',
|
||||
display: 'inline-flex'
|
||||
},
|
||||
|
||||
messageContent: {
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
flex: 1
|
||||
},
|
||||
|
||||
messageHeader: {
|
||||
...withPixelLineHeight(theme.typography.labelBold),
|
||||
color: theme.palette.text02,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
marginBottom: theme.spacing(1),
|
||||
maxWidth: '130px'
|
||||
},
|
||||
|
||||
messageText: {
|
||||
...withPixelLineHeight(theme.typography.bodyShortRegular),
|
||||
color: theme.palette.text01,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
},
|
||||
|
||||
timestamp: {
|
||||
...withPixelLineHeight(theme.typography.labelRegular),
|
||||
color: theme.palette.text03,
|
||||
marginTop: theme.spacing(1)
|
||||
},
|
||||
|
||||
interim: {
|
||||
opacity: 0.7
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that renders a single subtitle message with the participant's name,
|
||||
* message content, and timestamp.
|
||||
*
|
||||
* @param {IProps} props - The component props.
|
||||
* @returns {JSX.Element} - The rendered subtitle message.
|
||||
*/
|
||||
export default function SubtitleMessage({ participantId, text, timestamp, interim, showDisplayName }: IProps) {
|
||||
const { classes } = useStyles();
|
||||
const participantName = useSelector((state: any) =>
|
||||
getParticipantDisplayName(state, participantId));
|
||||
|
||||
return (
|
||||
<div className = { `${classes.messageContainer} ${interim ? classes.interim : ''}` }>
|
||||
<div className = { classes.messageContent }>
|
||||
{showDisplayName && (
|
||||
<div className = { classes.messageHeader }>
|
||||
{participantName}
|
||||
</div>
|
||||
)}
|
||||
<div className = { classes.messageText }>{text}</div>
|
||||
<div className = { classes.timestamp }>
|
||||
{new Date(timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
react/features/chat/components/web/SubtitlesGroup.tsx
Normal file
76
react/features/chat/components/web/SubtitlesGroup.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Avatar from '../../../base/avatar/components/Avatar';
|
||||
import { ISubtitle } from '../../../subtitles/types';
|
||||
|
||||
import SubtitleMessage from './SubtitleMessage';
|
||||
|
||||
/**
|
||||
* Props for the SubtitlesGroup component.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Array of subtitle messages to be displayed in this group.
|
||||
*/
|
||||
messages: ISubtitle[];
|
||||
|
||||
/**
|
||||
* The ID of the participant who sent these subtitles.
|
||||
*/
|
||||
senderId: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
groupContainer: {
|
||||
display: 'flex',
|
||||
marginBottom: theme.spacing(3)
|
||||
},
|
||||
|
||||
avatar: {
|
||||
marginRight: theme.spacing(2),
|
||||
alignSelf: 'flex-start'
|
||||
},
|
||||
|
||||
messagesContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
maxWidth: 'calc(100% - 56px)', // 40px avatar + 16px margin
|
||||
gap: theme.spacing(1)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that renders a group of subtitle messages from the same sender.
|
||||
*
|
||||
* @param {IProps} props - The props for the component.
|
||||
* @returns {JSX.Element} - A React component rendering a group of subtitles.
|
||||
*/
|
||||
export function SubtitlesGroup({ messages, senderId }: IProps) {
|
||||
const { classes } = useStyles();
|
||||
|
||||
if (!messages.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { classes.groupContainer }>
|
||||
<Avatar
|
||||
className = { classes.avatar }
|
||||
participantId = { senderId }
|
||||
size = { 32 } />
|
||||
<div className = { classes.messagesContainer }>
|
||||
{messages.map((message, index) => (
|
||||
<SubtitleMessage
|
||||
key = { `${message.timestamp}-${message.id}` }
|
||||
showDisplayName = { index === 0 }
|
||||
{ ...message } />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { scrollIntoView } from 'seamless-scroll-polyfill';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { ISubtitle } from '../../../subtitles/types';
|
||||
|
||||
import NewMessagesButton from './NewMessagesButton';
|
||||
import { SubtitlesGroup } from './SubtitlesGroup';
|
||||
|
||||
interface IProps {
|
||||
groups: Array<{
|
||||
messages: ISubtitle[];
|
||||
senderId: string;
|
||||
}>;
|
||||
messages: ISubtitle[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The padding value used for the message list.
|
||||
*
|
||||
* @constant {string}
|
||||
*/
|
||||
const MESSAGE_LIST_PADDING = '16px';
|
||||
|
||||
const useStyles = makeStyles()(() => {
|
||||
return {
|
||||
container: {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
height: '100%'
|
||||
},
|
||||
messagesList: {
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
padding: MESSAGE_LIST_PADDING,
|
||||
boxSizing: 'border-box'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that handles the display and scrolling behavior of subtitles messages.
|
||||
* It provides auto-scrolling for new messages and a button to jump to new messages
|
||||
* when the user has scrolled up.
|
||||
*
|
||||
* @returns {JSX.Element} - A React component displaying subtitles messages with scroll functionality.
|
||||
*/
|
||||
export function SubtitlesMessagesContainer({ messages, groups }: IProps) {
|
||||
const { classes } = useStyles();
|
||||
const [ hasNewMessages, setHasNewMessages ] = useState(false);
|
||||
const [ isScrolledToBottom, setIsScrolledToBottom ] = useState(true);
|
||||
const [ observer, setObserver ] = useState<IntersectionObserver | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToElement = useCallback((withAnimation: boolean, element: Element | null) => {
|
||||
const scrollTo = element ? element : messagesEndRef.current;
|
||||
const block = element ? 'end' : 'nearest';
|
||||
|
||||
scrollIntoView(scrollTo as Element, {
|
||||
behavior: withAnimation ? 'smooth' : 'auto',
|
||||
block
|
||||
});
|
||||
}, [ messagesEndRef.current ]);
|
||||
|
||||
const handleNewMessagesClick = useCallback(() => {
|
||||
scrollToElement(true, null);
|
||||
}, [ scrollToElement ]);
|
||||
|
||||
const handleIntersectBottomList = (entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry: IntersectionObserverEntry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsScrolledToBottom(true);
|
||||
setHasNewMessages(false);
|
||||
}
|
||||
|
||||
if (!entry.isIntersecting) {
|
||||
setIsScrolledToBottom(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createBottomListObserver = () => {
|
||||
const target = document.querySelector('#subtitles-messages-end');
|
||||
|
||||
if (target) {
|
||||
const newObserver = new IntersectionObserver(
|
||||
handleIntersectBottomList, {
|
||||
root: document.querySelector('#subtitles-messages-list'),
|
||||
rootMargin: MESSAGE_LIST_PADDING,
|
||||
threshold: 1
|
||||
});
|
||||
|
||||
setObserver(newObserver);
|
||||
newObserver.observe(target);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToElement(false, null);
|
||||
createBottomListObserver();
|
||||
|
||||
return () => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
setObserver(null);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const previousMessages = useRef(messages);
|
||||
|
||||
useEffect(() => {
|
||||
const newMessages = messages.filter(message => !previousMessages.current.includes(message));
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
if (isScrolledToBottom) {
|
||||
scrollToElement(false, null);
|
||||
} else {
|
||||
setHasNewMessages(true);
|
||||
}
|
||||
}
|
||||
|
||||
previousMessages.current = messages;
|
||||
},
|
||||
|
||||
// isScrolledToBottom is not a dependency because we neither need to show the new messages button neither scroll to the
|
||||
// bottom when the user has scrolled up.
|
||||
[ messages, scrollToElement ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { classes.container }
|
||||
id = 'subtitles-messages-container'>
|
||||
<div
|
||||
className = { classes.messagesList }
|
||||
id = 'subtitles-messages-list'>
|
||||
{groups.map(group => (
|
||||
<SubtitlesGroup
|
||||
key = { `${group.senderId}-${group.messages[0].timestamp}` }
|
||||
messages = { group.messages }
|
||||
senderId = { group.senderId } />
|
||||
))}
|
||||
{ !isScrolledToBottom && hasNewMessages && (
|
||||
<NewMessagesButton
|
||||
onGoToFirstUnreadMessage = { handleNewMessagesClick } />
|
||||
)}
|
||||
<div
|
||||
id = 'subtitles-messages-end'
|
||||
ref = { messagesEndRef } />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,10 +39,11 @@ export const SMALL_WIDTH_THRESHOLD = 580;
|
||||
*/
|
||||
export const LOBBY_CHAT_MESSAGE = 'LOBBY_CHAT_MESSAGE';
|
||||
|
||||
export const CHAT_TABS = {
|
||||
POLLS: 'polls-tab',
|
||||
CHAT: 'chat-tab'
|
||||
};
|
||||
export enum ChatTabs {
|
||||
CHAT = 'chat-tab',
|
||||
CLOSED_CAPTIONS = 'cc-tab',
|
||||
POLLS = 'polls-tab'
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatter string to display the message timestamp.
|
||||
|
||||
@@ -40,11 +40,12 @@ import {
|
||||
OPEN_CHAT,
|
||||
SEND_MESSAGE,
|
||||
SEND_REACTION,
|
||||
SET_IS_POLL_TAB_FOCUSED
|
||||
SET_FOCUSED_TAB
|
||||
} from './actionTypes';
|
||||
import { addMessage, addMessageReaction, clearMessages, closeChat, setPrivateMessageRecipient } from './actions.any';
|
||||
import { ChatPrivacyDialog } from './components';
|
||||
import {
|
||||
ChatTabs,
|
||||
INCOMING_MSG_SOUND_ID,
|
||||
LOBBY_CHAT_MESSAGE,
|
||||
MESSAGE_TYPE_ERROR,
|
||||
@@ -103,15 +104,15 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
break;
|
||||
|
||||
case CLOSE_CHAT: {
|
||||
const isPollTabOpen = getState()['features/chat'].isPollsTabFocused;
|
||||
const { focusedTab } = getState()['features/chat'];
|
||||
|
||||
unreadCount = 0;
|
||||
if (focusedTab === ChatTabs.CHAT) {
|
||||
unreadCount = 0;
|
||||
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyChatUpdated(unreadCount, false);
|
||||
}
|
||||
|
||||
if (isPollTabOpen) {
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyChatUpdated(unreadCount, false);
|
||||
}
|
||||
} else if (focusedTab === ChatTabs.POLLS) {
|
||||
dispatch(resetNbUnreadPollsMessages());
|
||||
}
|
||||
break;
|
||||
@@ -161,35 +162,37 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
break;
|
||||
}
|
||||
|
||||
case SET_FOCUSED_TAB:
|
||||
case OPEN_CHAT: {
|
||||
unreadCount = 0;
|
||||
const focusedTab = action.tabId || getState()['features/chat'].focusedTab;
|
||||
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyChatUpdated(unreadCount, true);
|
||||
}
|
||||
if (focusedTab === ChatTabs.CHAT) {
|
||||
unreadCount = 0;
|
||||
|
||||
const { privateMessageRecipient } = store.getState()['features/chat'];
|
||||
|
||||
if (
|
||||
isSendGroupChatDisabled(store.getState())
|
||||
&& privateMessageRecipient
|
||||
&& !action.participant
|
||||
) {
|
||||
const participant = getParticipantById(store.getState(), privateMessageRecipient.id);
|
||||
|
||||
if (participant) {
|
||||
action.participant = participant;
|
||||
if (typeof APP !== 'undefined') {
|
||||
APP.API.notifyChatUpdated(unreadCount, true);
|
||||
}
|
||||
|
||||
const { privateMessageRecipient } = store.getState()['features/chat'];
|
||||
|
||||
if (
|
||||
isSendGroupChatDisabled(store.getState())
|
||||
&& privateMessageRecipient
|
||||
&& !action.participant
|
||||
) {
|
||||
const participant = getParticipantById(store.getState(), privateMessageRecipient.id);
|
||||
|
||||
if (participant) {
|
||||
action.participant = participant;
|
||||
}
|
||||
}
|
||||
} else if (focusedTab === ChatTabs.POLLS) {
|
||||
dispatch(resetNbUnreadPollsMessages());
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case SET_IS_POLL_TAB_FOCUSED: {
|
||||
dispatch(resetNbUnreadPollsMessages());
|
||||
break;
|
||||
}
|
||||
|
||||
case SEND_MESSAGE: {
|
||||
const state = store.getState();
|
||||
const conference = getCurrentConference(state);
|
||||
@@ -256,7 +259,6 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
lobbyChat: false
|
||||
}, false, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,8 +532,7 @@ function _handleReceivedMessage({ dispatch, getState }: IStore,
|
||||
|
||||
// skip message notifications on join (the messages having timestamp - coming from the history)
|
||||
const shouldShowNotification = userSelectedNotifications?.['notify.chatMessages']
|
||||
&& !hasRead && !isReaction
|
||||
&& (!timestamp || lobbyChat);
|
||||
&& !hasRead && !isReaction && (!timestamp || lobbyChat);
|
||||
|
||||
if (isGuest) {
|
||||
displayNameToShow = `${displayNameToShow} ${i18next.t('visitors.chatIndicator')}`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ILocalParticipant, IParticipant } from '../base/participants/types';
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
import { ChatTabs } from './constants';
|
||||
|
||||
import {
|
||||
ADD_MESSAGE,
|
||||
@@ -9,10 +10,10 @@ import {
|
||||
EDIT_MESSAGE,
|
||||
OPEN_CHAT,
|
||||
REMOVE_LOBBY_CHAT_PARTICIPANT,
|
||||
SET_IS_POLL_TAB_FOCUSED,
|
||||
SET_LOBBY_CHAT_ACTIVE_STATE,
|
||||
SET_LOBBY_CHAT_RECIPIENT,
|
||||
SET_PRIVATE_MESSAGE_RECIPIENT
|
||||
SET_PRIVATE_MESSAGE_RECIPIENT,
|
||||
SET_FOCUSED_TAB
|
||||
} from './actionTypes';
|
||||
import { IMessage } from './types';
|
||||
import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes';
|
||||
@@ -20,21 +21,20 @@ import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes';
|
||||
const DEFAULT_STATE = {
|
||||
groupChatWithPermissions: false,
|
||||
isOpen: false,
|
||||
isPollsTabFocused: false,
|
||||
lastReadMessage: undefined,
|
||||
messages: [],
|
||||
reactions: {},
|
||||
nbUnreadMessages: 0,
|
||||
privateMessageRecipient: undefined,
|
||||
lobbyMessageRecipient: undefined,
|
||||
isLobbyChatActive: false
|
||||
isLobbyChatActive: false,
|
||||
focusedTab: ChatTabs.CHAT
|
||||
};
|
||||
|
||||
export interface IChatState {
|
||||
focusedTab: ChatTabs;
|
||||
groupChatWithPermissions: boolean;
|
||||
isLobbyChatActive: boolean;
|
||||
isOpen: boolean;
|
||||
isPollsTabFocused: boolean;
|
||||
lastReadMessage?: IMessage;
|
||||
lobbyMessageRecipient?: {
|
||||
id: string;
|
||||
@@ -78,7 +78,7 @@ ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, ac
|
||||
...state,
|
||||
lastReadMessage:
|
||||
action.hasRead ? newMessage : state.lastReadMessage,
|
||||
nbUnreadMessages: state.isPollsTabFocused ? state.nbUnreadMessages + 1 : state.nbUnreadMessages,
|
||||
nbUnreadMessages: state.focusedTab !== ChatTabs.CHAT ? state.nbUnreadMessages + 1 : state.nbUnreadMessages,
|
||||
messages
|
||||
};
|
||||
}
|
||||
@@ -170,13 +170,6 @@ ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, ac
|
||||
isLobbyChatActive: false
|
||||
};
|
||||
|
||||
case SET_IS_POLL_TAB_FOCUSED: {
|
||||
return {
|
||||
...state,
|
||||
isPollsTabFocused: action.isPollsTabFocused,
|
||||
nbUnreadMessages: 0
|
||||
}; }
|
||||
|
||||
case SET_LOBBY_CHAT_RECIPIENT:
|
||||
return {
|
||||
...state,
|
||||
@@ -215,7 +208,15 @@ ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, ac
|
||||
groupChatWithPermissions: Boolean(metadata.permissions.groupChatRestricted)
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case SET_FOCUSED_TAB:
|
||||
return {
|
||||
...state,
|
||||
focusedTab: action.tabId,
|
||||
nbUnreadMessages: action.tabId === ChatTabs.CHAT ? 0 : state.nbUnreadMessages
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
|
||||
@@ -249,6 +249,8 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<IProps, IState> {
|
||||
const { item } = flatListItem;
|
||||
|
||||
switch (item.type) {
|
||||
|
||||
// isCORSAvatarURL in this case is false
|
||||
case INVITE_TYPES.PHONE:
|
||||
return {
|
||||
avatar: IconPhoneRinging,
|
||||
|
||||
@@ -11,11 +11,13 @@ import Watermarks from '../../base/react/components/web/Watermarks';
|
||||
import { getHideSelfView } from '../../base/settings/functions.any';
|
||||
import { getVideoTrackByParticipant } from '../../base/tracks/functions.web';
|
||||
import { setColorAlpha } from '../../base/util/helpers';
|
||||
import { isSpotTV } from '../../base/util/spot';
|
||||
import StageParticipantNameLabel from '../../display-name/components/web/StageParticipantNameLabel';
|
||||
import { FILMSTRIP_BREAKPOINT } from '../../filmstrip/constants';
|
||||
import { getVerticalViewMaxWidth, isFilmstripResizable } from '../../filmstrip/functions.web';
|
||||
import SharedVideo from '../../shared-video/components/web/SharedVideo';
|
||||
import Captions from '../../subtitles/components/web/Captions';
|
||||
import { areClosedCaptionsEnabled } from '../../subtitles/functions.any';
|
||||
import { setTileView } from '../../video-layout/actions.web';
|
||||
import Whiteboard from '../../whiteboard/components/web/Whiteboard';
|
||||
import { isWhiteboardEnabled } from '../../whiteboard/functions';
|
||||
@@ -24,8 +26,6 @@ import { getLargeVideoParticipant } from '../functions';
|
||||
|
||||
import ScreenSharePlaceholder from './ScreenSharePlaceholder.web';
|
||||
|
||||
// Hack to detect Spot.
|
||||
const SPOT_DISPLAY_NAME = 'Meeting Room';
|
||||
|
||||
interface IProps {
|
||||
|
||||
@@ -100,6 +100,11 @@ interface IProps {
|
||||
*/
|
||||
_showDominantSpeakerBadge: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not to show subtitles button.
|
||||
*/
|
||||
_showSubtitles?: boolean;
|
||||
|
||||
/**
|
||||
* The width of the vertical filmstrip (user resized).
|
||||
*/
|
||||
@@ -200,7 +205,8 @@ class LargeVideo extends Component<IProps> {
|
||||
_isDisplayNameVisible,
|
||||
_noAutoPlayVideo,
|
||||
_showDominantSpeakerBadge,
|
||||
_whiteboardEnabled
|
||||
_whiteboardEnabled,
|
||||
_showSubtitles
|
||||
} = this.props;
|
||||
const style = this._getCustomStyles();
|
||||
const className = `videocontainer${_isChatOpen ? ' shift-right' : ''}`;
|
||||
@@ -248,8 +254,8 @@ class LargeVideo extends Component<IProps> {
|
||||
playsInline = { true } /* for Safari on iOS to work */ />
|
||||
</div>
|
||||
</div>
|
||||
{ interfaceConfig.DISABLE_TRANSCRIPTION_SUBTITLES
|
||||
|| <Captions /> }
|
||||
{ (!interfaceConfig.DISABLE_TRANSCRIPTION_SUBTITLES && _showSubtitles)
|
||||
&& <Captions /> }
|
||||
{
|
||||
_isDisplayNameVisible
|
||||
&& (
|
||||
@@ -364,20 +370,20 @@ function _mapStateToProps(state: IReduxState) {
|
||||
const { backgroundColor, backgroundImageUrl } = state['features/dynamic-branding'];
|
||||
const { isOpen: isChatOpen } = state['features/chat'];
|
||||
const { width: verticalFilmstripWidth, visible } = state['features/filmstrip'];
|
||||
const { defaultLocalDisplayName, hideDominantSpeakerBadge } = state['features/base/config'];
|
||||
const { hideDominantSpeakerBadge } = state['features/base/config'];
|
||||
const { seeWhatIsBeingShared } = state['features/large-video'];
|
||||
const localParticipantId = getLocalParticipant(state)?.id;
|
||||
const largeVideoParticipant = getLargeVideoParticipant(state);
|
||||
const videoTrack = getVideoTrackByParticipant(state, largeVideoParticipant);
|
||||
const isLocalScreenshareOnLargeVideo = largeVideoParticipant?.id?.includes(localParticipantId ?? '')
|
||||
&& videoTrack?.videoType === VIDEO_TYPE.DESKTOP;
|
||||
const isOnSpot = defaultLocalDisplayName === SPOT_DISPLAY_NAME;
|
||||
|
||||
return {
|
||||
_backgroundAlpha: state['features/base/config'].backgroundAlpha,
|
||||
_customBackgroundColor: backgroundColor,
|
||||
_customBackgroundImageUrl: backgroundImageUrl,
|
||||
_displayScreenSharingPlaceholder: Boolean(isLocalScreenshareOnLargeVideo && !seeWhatIsBeingShared && !isOnSpot),
|
||||
_displayScreenSharingPlaceholder:
|
||||
Boolean(isLocalScreenshareOnLargeVideo && !seeWhatIsBeingShared && !isSpotTV()),
|
||||
_hideSelfView: getHideSelfView(state),
|
||||
_isChatOpen: isChatOpen,
|
||||
_isDisplayNameVisible: isDisplayNameVisible(state),
|
||||
@@ -388,6 +394,8 @@ function _mapStateToProps(state: IReduxState) {
|
||||
_resizableFilmstrip: isFilmstripResizable(state),
|
||||
_seeWhatIsBeingShared: Boolean(seeWhatIsBeingShared),
|
||||
_showDominantSpeakerBadge: !hideDominantSpeakerBadge,
|
||||
_showSubtitles: areClosedCaptionsEnabled(state)
|
||||
&& Boolean(state['features/base/settings'].showSubtitlesOnStage),
|
||||
_verticalFilmstripWidth: verticalFilmstripWidth.current,
|
||||
_verticalViewMaxWidth: getVerticalViewMaxWidth(state),
|
||||
_visibleFilmstrip: visible,
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
getClientHeight,
|
||||
getClientWidth
|
||||
} from '../../../../../base/modal/components/functions';
|
||||
import { setIsPollsTabFocused } from '../../../../../chat/actions.native';
|
||||
// @ts-ignore
|
||||
import { setFocusedTab } from '../../../../../chat/actions.any';
|
||||
import Chat from '../../../../../chat/components/native/Chat';
|
||||
import { ChatTabs } from '../../../../../chat/constants';
|
||||
import { resetNbUnreadPollsMessages } from '../../../../../polls/actions';
|
||||
import PollsPane from '../../../../../polls/components/native/PollsPane';
|
||||
import { screen } from '../../../routes';
|
||||
@@ -23,8 +23,8 @@ const ChatAndPolls = () => {
|
||||
const clientHeight = useSelector(getClientHeight);
|
||||
const clientWidth = useSelector(getClientWidth);
|
||||
const dispatch = useDispatch();
|
||||
const { isPollsTabFocused } = useSelector((state: IReduxState) => state['features/chat']);
|
||||
const initialRouteName = isPollsTabFocused
|
||||
const { focusedTab } = useSelector((state: IReduxState) => state['features/chat']);
|
||||
const initialRouteName = focusedTab === ChatTabs.POLLS
|
||||
? screen.conference.chatandpolls.tab.polls
|
||||
: screen.conference.chatandpolls.tab.chat;
|
||||
|
||||
@@ -42,7 +42,7 @@ const ChatAndPolls = () => {
|
||||
component = { Chat }
|
||||
listeners = {{
|
||||
tabPress: () => {
|
||||
dispatch(setIsPollsTabFocused(false));
|
||||
dispatch(setFocusedTab(ChatTabs.CHAT));
|
||||
}
|
||||
}}
|
||||
name = { screen.conference.chatandpolls.tab.chat } />
|
||||
@@ -50,7 +50,7 @@ const ChatAndPolls = () => {
|
||||
component = { PollsPane }
|
||||
listeners = {{
|
||||
tabPress: () => {
|
||||
dispatch(setIsPollsTabFocused(true));
|
||||
dispatch(setFocusedTab(ChatTabs.POLLS));
|
||||
dispatch(resetNbUnreadPollsMessages);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -130,6 +130,7 @@ function MeetingParticipants({
|
||||
accessibilityLabel = { t('participantsPane.search') }
|
||||
className = { styles.search }
|
||||
clearable = { true }
|
||||
hiddenDescription = { t('participantsPane.searchDescription') }
|
||||
id = 'participants-search-input'
|
||||
onChange = { setSearchString }
|
||||
placeholder = { t('participantsPane.search') }
|
||||
|
||||
@@ -10,6 +10,7 @@ import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
import { StyleType } from '../../../base/styles/functions.any';
|
||||
import Button from '../../../base/ui/components/native/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
|
||||
import { ChatTabs } from '../../../chat/constants';
|
||||
import { TabBarLabelCounter }
|
||||
from '../../../mobile/navigation/components/TabBarLabelCounter';
|
||||
import AbstractPollsPane from '../AbstractPollsPane';
|
||||
@@ -22,7 +23,7 @@ import { pollsStyles } from './styles';
|
||||
const PollsPane = (props: AbstractProps) => {
|
||||
const { createMode, isCreatePollsDisabled, onCreate, setCreateMode, t } = props;
|
||||
const navigation = useNavigation();
|
||||
const { isPollsTabFocused } = useSelector((state: IReduxState) => state['features/chat']);
|
||||
const isPollsTabFocused = useSelector((state: IReduxState) => state['features/chat'].focusedTab === ChatTabs.POLLS);
|
||||
const { nbUnreadPolls } = useSelector((state: IReduxState) => state['features/polls']);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getCurrentConference } from '../base/conference/functions';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { playSound } from '../base/sounds/actions';
|
||||
import { INCOMING_MSG_SOUND_ID } from '../chat/constants';
|
||||
import { ChatTabs, INCOMING_MSG_SOUND_ID } from '../chat/constants';
|
||||
import { arePollsDisabled } from '../conference/functions.any';
|
||||
import { showNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants';
|
||||
@@ -96,7 +96,7 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
|
||||
}
|
||||
|
||||
const isChatOpen: boolean = state['features/chat'].isOpen;
|
||||
const isPollsTabFocused: boolean = state['features/chat'].isPollsTabFocused;
|
||||
const isPollsTabFocused: boolean = state['features/chat'].focusedTab === ChatTabs.POLLS;
|
||||
|
||||
// Finally, we notify user they received a new poll if their pane is not opened
|
||||
if (action.notify && (!isChatOpen || !isPollsTabFocused)) {
|
||||
|
||||
@@ -285,10 +285,19 @@ export function showStartedRecordingNotification(
|
||||
|
||||
// add the option to copy recording link
|
||||
if (showRecordingLink) {
|
||||
const actions = [
|
||||
...notifyProps.dialogProps.customActionNameKey ?? [],
|
||||
'recording.copyLink'
|
||||
];
|
||||
const handlers = [
|
||||
...notifyProps.dialogProps.customActionHandler ?? [],
|
||||
() => copyText(link)
|
||||
];
|
||||
|
||||
notifyProps.dialogProps = {
|
||||
...notifyProps.dialogProps,
|
||||
customActionNameKey: [ 'recording.copyLink' ],
|
||||
customActionHandler: [ () => copyText(link) ],
|
||||
customActionNameKey: actions,
|
||||
customActionHandler: handlers,
|
||||
titleKey: 'recording.on',
|
||||
descriptionKey: 'recording.linkGenerated'
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { IStore } from '../../../app/types';
|
||||
interface ILocalRecordingManager {
|
||||
addAudioTrackToLocalRecording: (track: any) => void;
|
||||
isRecordingLocally: () => boolean;
|
||||
isSupported: () => boolean;
|
||||
selfRecording: {
|
||||
on: boolean;
|
||||
withVideo: boolean;
|
||||
@@ -40,6 +41,15 @@ const LocalRecordingManager: ILocalRecordingManager = {
|
||||
*/
|
||||
async startLocalRecording() { }, // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Whether or not local recording is supported.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSupported() {
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether or not we're currently recording locally.
|
||||
*
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import i18next from 'i18next';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import fixWebmDuration from 'webm-duration-fix';
|
||||
|
||||
import { IStore } from '../../../app/types';
|
||||
import { getRoomName } from '../../../base/conference/functions';
|
||||
import { MEDIA_TYPE } from '../../../base/media/constants';
|
||||
import { getLocalTrack, getTrackState } from '../../../base/tracks/functions';
|
||||
import { isMobileBrowser } from '../../../base/environment/utils';
|
||||
import { browser } from '../../../base/lib-jitsi-meet';
|
||||
import { isEmbedded } from '../../../base/util/embedUtils';
|
||||
import { stopLocalVideoRecording } from '../../actions.any';
|
||||
|
||||
@@ -18,63 +19,54 @@ interface ILocalRecordingManager {
|
||||
addAudioTrackToLocalRecording: (track: MediaStreamTrack) => void;
|
||||
audioContext: AudioContext | undefined;
|
||||
audioDestination: MediaStreamAudioDestinationNode | undefined;
|
||||
fileHandle: FileSystemFileHandle | undefined;
|
||||
getFilename: () => string;
|
||||
initializeAudioMixer: () => void;
|
||||
isRecordingLocally: () => boolean;
|
||||
isSupported: () => boolean;
|
||||
mediaType: string;
|
||||
mixAudioStream: (stream: MediaStream) => void;
|
||||
recorder: MediaRecorder | undefined;
|
||||
recordingData: Blob[];
|
||||
roomName: string;
|
||||
saveRecording: (recordingData: Blob[], filename: string) => void;
|
||||
selfRecording: ISelfRecording;
|
||||
startLocalRecording: (store: IStore, onlySelf: boolean) => Promise<void>;
|
||||
stopLocalRecording: () => void;
|
||||
stream: MediaStream | undefined;
|
||||
totalSize: number;
|
||||
writableStream: FileSystemWritableFileStream | undefined;
|
||||
}
|
||||
|
||||
const getMimeType = (): string => {
|
||||
const possibleTypes = [
|
||||
'video/webm;codecs=vp8'
|
||||
];
|
||||
|
||||
for (const type of possibleTypes) {
|
||||
if (MediaRecorder.isTypeSupported(type)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
throw new Error('No MIME Type supported by MediaRecorder');
|
||||
};
|
||||
/**
|
||||
* We want to use the MP4 container due to it not suffering from the resulting file
|
||||
* not being seek-able.
|
||||
*
|
||||
* The choice of VP9 as the video codec and Opus as the audio codec is for compatibility.
|
||||
* While Chrome does support avc1 and avc3 (we'd need the latter since the resolution can change)
|
||||
* it's not supported across the board.
|
||||
*/
|
||||
const PREFERRED_MEDIA_TYPE = 'video/mp4;codecs=vp9,opus';
|
||||
|
||||
const VIDEO_BIT_RATE = 2500000; // 2.5Mbps in bits
|
||||
const MAX_SIZE = 1073741824; // 1GB in bytes
|
||||
|
||||
// Lazily initialize.
|
||||
let preferredMediaType: string;
|
||||
|
||||
const LocalRecordingManager: ILocalRecordingManager = {
|
||||
recordingData: [],
|
||||
recorder: undefined,
|
||||
stream: undefined,
|
||||
audioContext: undefined,
|
||||
audioDestination: undefined,
|
||||
roomName: '',
|
||||
totalSize: MAX_SIZE,
|
||||
selfRecording: {
|
||||
on: false,
|
||||
withVideo: false
|
||||
},
|
||||
fileHandle: undefined,
|
||||
writableStream: undefined,
|
||||
|
||||
get mediaType() {
|
||||
if (this.selfRecording.on && !this.selfRecording.withVideo) {
|
||||
return 'audio/webm;';
|
||||
}
|
||||
if (!preferredMediaType) {
|
||||
preferredMediaType = getMimeType();
|
||||
}
|
||||
|
||||
return preferredMediaType;
|
||||
return PREFERRED_MEDIA_TYPE;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -128,27 +120,6 @@ const LocalRecordingManager: ILocalRecordingManager = {
|
||||
return `${this.roomName}_${timestamp}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Saves local recording to file.
|
||||
*
|
||||
* @param {Array} recordingData - The recording data.
|
||||
* @param {string} filename - The name of the file.
|
||||
* @returns {void}
|
||||
* */
|
||||
async saveRecording(recordingData, filename) {
|
||||
// @ts-ignore
|
||||
const blob = await fixWebmDuration(new Blob(recordingData, { type: this.mediaType }));
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
const extension = this.mediaType.slice(this.mediaType.indexOf('/') + 1, this.mediaType.indexOf(';'));
|
||||
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = `${filename}.${extension}`;
|
||||
a.click();
|
||||
},
|
||||
|
||||
/**
|
||||
* Stops local recording.
|
||||
*
|
||||
@@ -160,12 +131,10 @@ const LocalRecordingManager: ILocalRecordingManager = {
|
||||
this.recorder = undefined;
|
||||
this.audioContext = undefined;
|
||||
this.audioDestination = undefined;
|
||||
this.totalSize = MAX_SIZE;
|
||||
setTimeout(() => {
|
||||
if (this.recordingData.length > 0) {
|
||||
this.saveRecording(this.recordingData, this.getFilename());
|
||||
}
|
||||
}, 1000);
|
||||
this.writableStream?.close().then(() => {
|
||||
this.fileHandle = undefined;
|
||||
this.writableStream = undefined;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -179,13 +148,23 @@ const LocalRecordingManager: ILocalRecordingManager = {
|
||||
async startLocalRecording(store, onlySelf) {
|
||||
const { dispatch, getState } = store;
|
||||
|
||||
this.roomName = getRoomName(getState()) ?? '';
|
||||
|
||||
// Get a handle to the file we are going to write.
|
||||
const options = {
|
||||
startIn: 'downloads',
|
||||
suggestedName: `${this.getFilename()}.mp4`,
|
||||
};
|
||||
|
||||
// @ts-expect-error
|
||||
this.fileHandle = await window.showSaveFilePicker(options);
|
||||
this.writableStream = await this.fileHandle?.createWritable();
|
||||
|
||||
// @ts-ignore
|
||||
const supportsCaptureHandle = Boolean(navigator.mediaDevices.setCaptureHandleConfig) && !isEmbedded();
|
||||
const tabId = uuidV4();
|
||||
|
||||
this.selfRecording.on = onlySelf;
|
||||
this.recordingData = [];
|
||||
this.roomName = getRoomName(getState()) ?? '';
|
||||
let gdmStream: MediaStream = new MediaStream();
|
||||
const tracks = getTrackState(getState());
|
||||
|
||||
@@ -280,13 +259,9 @@ const LocalRecordingManager: ILocalRecordingManager = {
|
||||
mimeType: this.mediaType,
|
||||
videoBitsPerSecond: VIDEO_BIT_RATE
|
||||
});
|
||||
this.recorder.addEventListener('dataavailable', e => {
|
||||
if (e.data && e.data.size > 0) {
|
||||
this.recordingData.push(e.data);
|
||||
this.totalSize -= e.data.size;
|
||||
if (this.totalSize <= 0) {
|
||||
dispatch(stopLocalVideoRecording());
|
||||
}
|
||||
this.recorder.addEventListener('dataavailable', async e => {
|
||||
if (this.recorder && e.data && e.data.size > 0) {
|
||||
await this.writableStream?.write(e.data);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -308,6 +283,22 @@ const LocalRecordingManager: ILocalRecordingManager = {
|
||||
this.recorder.start(5000);
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether or not local recording is supported.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSupported() {
|
||||
return browser.isChromiumBased()
|
||||
&& !browser.isElectron()
|
||||
&& !browser.isReactNative()
|
||||
&& !isMobileBrowser()
|
||||
|
||||
// @ts-expect-error
|
||||
&& typeof window.showSaveFilePicker !== 'undefined'
|
||||
&& MediaRecorder.isTypeSupported(PREFERRED_MEDIA_TYPE);
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether or not we're currently recording locally.
|
||||
*
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import i18next from 'i18next';
|
||||
|
||||
import { IReduxState, IStore } from '../app/types';
|
||||
import { isMobileBrowser } from '../base/environment/utils';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
import { JitsiRecordingConstants, browser } from '../base/lib-jitsi-meet';
|
||||
import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
|
||||
import { getSoundFileSrc } from '../base/media/functions';
|
||||
import { getLocalParticipant, getRemoteParticipants } from '../base/participants/functions';
|
||||
import { registerSound, unregisterSound } from '../base/sounds/actions';
|
||||
import { isSpotTV } from '../base/util/spot';
|
||||
import { isInBreakoutRoom as isInBreakoutRoomF } from '../breakout-rooms/functions';
|
||||
import { isEnabled as isDropboxEnabled } from '../dropbox/functions';
|
||||
import { extractFqnFromPath } from '../dynamic-branding/functions.any';
|
||||
@@ -151,8 +151,7 @@ export function getSessionStatusToShow(state: IReduxState, mode: string): string
|
||||
* @returns {boolean} - Whether local recording is supported or not.
|
||||
*/
|
||||
export function supportsLocalRecording() {
|
||||
return browser.isChromiumBased() && !browser.isElectron() && !isMobileBrowser()
|
||||
&& navigator.product !== 'ReactNative';
|
||||
return LocalRecordingManager.isSupported();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -448,6 +447,10 @@ export function shouldRequireRecordingConsent(recorderSession: any, state: IRedu
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isSpotTV()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!requireConsent && !requireRecordingConsent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
/* eslint-disable lines-around-comment */
|
||||
import {
|
||||
PC_CON_STATE_CHANGE,
|
||||
PC_STATE_CONNECTED,
|
||||
PC_STATE_FAILED
|
||||
// @ts-expect-error
|
||||
} from '@jitsi/rtcstats/events';
|
||||
|
||||
import JitsiMeetJS, { RTCStatsEvents } from '../base/lib-jitsi-meet';
|
||||
|
||||
import logger from './logger';
|
||||
@@ -16,6 +8,11 @@ import {
|
||||
VideoTypeData
|
||||
} from './types';
|
||||
|
||||
// TODO(saghul): expose these in libn-jitsi-meet?
|
||||
const PC_CON_STATE_CHANGE = 'connectionstatechange';
|
||||
const PC_STATE_CONNECTED = 'connected';
|
||||
const PC_STATE_FAILED = 'failed';
|
||||
|
||||
/**
|
||||
* Handle lib-jitsi-meet rtcstats events and send jitsi-meet specific statistics.
|
||||
*/
|
||||
|
||||
@@ -155,6 +155,10 @@ export function submitMoreTab(newState: any) {
|
||||
|
||||
conference?.setTranscriptionLanguage(newState.currentLanguage);
|
||||
}
|
||||
|
||||
if (newState.showSubtitlesOnStage !== currentState.showSubtitlesOnStage) {
|
||||
dispatch(updateSettings({ showSubtitlesOnStage: newState.showSubtitlesOnStage }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ import { MAX_ACTIVE_PARTICIPANTS } from '../../../filmstrip/constants';
|
||||
*/
|
||||
export interface IProps extends AbstractDialogTabProps, WithTranslation {
|
||||
|
||||
/**
|
||||
* Indicates if closed captions are enabled.
|
||||
*/
|
||||
areClosedCaptionsEnabled: boolean;
|
||||
|
||||
/**
|
||||
* CSS classes object.
|
||||
*/
|
||||
@@ -78,6 +83,11 @@ export interface IProps extends AbstractDialogTabProps, WithTranslation {
|
||||
*/
|
||||
showPrejoinSettings: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not to show subtitles on stage.
|
||||
*/
|
||||
showSubtitlesOnStage: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the stage filmstrip is enabled.
|
||||
*/
|
||||
@@ -126,6 +136,7 @@ class MoreTab extends AbstractDialogTab<IProps, any> {
|
||||
this._renderMaxStageParticipantsSelect = this._renderMaxStageParticipantsSelect.bind(this);
|
||||
this._onMaxStageParticipantsSelect = this._onMaxStageParticipantsSelect.bind(this);
|
||||
this._onHideSelfViewChanged = this._onHideSelfViewChanged.bind(this);
|
||||
this._onShowSubtitlesOnStageChanged = this._onShowSubtitlesOnStageChanged.bind(this);
|
||||
this._onLanguageItemSelect = this._onLanguageItemSelect.bind(this);
|
||||
}
|
||||
|
||||
@@ -137,11 +148,13 @@ class MoreTab extends AbstractDialogTab<IProps, any> {
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
areClosedCaptionsEnabled,
|
||||
showPrejoinSettings,
|
||||
disableHideSelfView,
|
||||
iAmVisitor,
|
||||
hideSelfView,
|
||||
showLanguageSettings,
|
||||
showSubtitlesOnStage,
|
||||
t
|
||||
} = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
@@ -163,6 +176,12 @@ class MoreTab extends AbstractDialogTab<IProps, any> {
|
||||
name = 'hide-self-view'
|
||||
onChange = { this._onHideSelfViewChanged } />
|
||||
)}
|
||||
{areClosedCaptionsEnabled && <Checkbox
|
||||
checked = { showSubtitlesOnStage }
|
||||
className = { classes.checkbox }
|
||||
label = { t('settings.showSubtitlesOnStage') }
|
||||
name = 'show-subtitles-button'
|
||||
onChange = { this._onShowSubtitlesOnStageChanged } /> }
|
||||
{showLanguageSettings && this._renderLanguageSelect()}
|
||||
</div>
|
||||
);
|
||||
@@ -204,6 +223,17 @@ class MoreTab extends AbstractDialogTab<IProps, any> {
|
||||
super._onChange({ hideSelfView: checked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select if show subtitles button should be enabled.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onShowSubtitlesOnStageChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) {
|
||||
super._onChange({ showSubtitlesOnStage: checked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select a language from select dropdown.
|
||||
*
|
||||
|
||||
@@ -316,6 +316,7 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
currentLanguage: tabState?.currentLanguage,
|
||||
hideSelfView: tabState?.hideSelfView,
|
||||
showPrejoinPage: tabState?.showPrejoinPage,
|
||||
showSubtitlesOnStage: tabState?.showSubtitlesOnStage,
|
||||
maxStageParticipants: tabState?.maxStageParticipants
|
||||
};
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import { isStageFilmstripEnabled } from '../filmstrip/functions';
|
||||
import { isFollowMeActive, isFollowMeRecorderActive } from '../follow-me/functions';
|
||||
import { isPrejoinEnabledInConfig } from '../prejoin/functions';
|
||||
import { isReactionsEnabled } from '../reactions/functions.any';
|
||||
import { areClosedCaptionsEnabled } from '../subtitles/functions.any';
|
||||
import { iAmVisitor } from '../visitors/functions';
|
||||
|
||||
import { shouldShowModeratorSettings } from './functions';
|
||||
@@ -107,6 +108,7 @@ export function getMoreTabProps(stateful: IStateful) {
|
||||
const { disableSelfView, disableSelfViewSettings } = state['features/base/config'];
|
||||
|
||||
return {
|
||||
areClosedCaptionsEnabled: areClosedCaptionsEnabled(state),
|
||||
currentLanguage: language,
|
||||
disableHideSelfView: disableSelfViewSettings || disableSelfView,
|
||||
hideSelfView: getHideSelfView(state),
|
||||
@@ -116,6 +118,7 @@ export function getMoreTabProps(stateful: IStateful) {
|
||||
showLanguageSettings: configuredTabs.includes('language'),
|
||||
showPrejoinPage: !state['features/base/settings'].userSelectedSkipPrejoin,
|
||||
showPrejoinSettings: isPrejoinEnabledInConfig(state),
|
||||
showSubtitlesOnStage: state['features/base/settings'].showSubtitlesOnStage,
|
||||
stageFilmstripEnabled
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getFieldValue } from '../../../base/react/functions';
|
||||
import { withPixelLineHeight } from '../../../base/styles/functions.web';
|
||||
import { MOBILE_BREAKPOINT } from '../../constants';
|
||||
import { isSpeakerStatsSearchDisabled } from '../../functions';
|
||||
import { HiddenDescription } from '../../../base/ui/components/web/HiddenDescription';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
@@ -96,6 +97,9 @@ function SpeakerStatsSearch({ onSearch }: IProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputId = 'speaker-stats-search';
|
||||
const inputDescriptionId = `${inputId}-hidden-description`;
|
||||
|
||||
return (
|
||||
<div className = { classes.speakerStatsSearchContainer }>
|
||||
<Icon
|
||||
@@ -103,17 +107,21 @@ function SpeakerStatsSearch({ onSearch }: IProps) {
|
||||
color = { theme.palette.icon03 }
|
||||
src = { IconSearch } />
|
||||
<input
|
||||
aria-describedby = { inputDescriptionId }
|
||||
aria-label = { t('speakerStats.searchHint') }
|
||||
autoComplete = 'off'
|
||||
autoFocus = { false }
|
||||
className = { classes.speakerStatsSearch }
|
||||
id = 'speaker-stats-search'
|
||||
id = { inputId }
|
||||
name = 'speakerStatsSearch'
|
||||
onChange = { onChange }
|
||||
onKeyPress = { preventDismiss }
|
||||
placeholder = { t('speakerStats.search') }
|
||||
tabIndex = { 0 }
|
||||
value = { searchValue } />
|
||||
<HiddenDescription id = { inputDescriptionId }>
|
||||
{t('speakerStats.searchDescription')}
|
||||
</HiddenDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,3 +55,8 @@ export const TOGGLE_REQUESTING_SUBTITLES
|
||||
*/
|
||||
export const SET_REQUESTING_SUBTITLES
|
||||
= 'SET_REQUESTING_SUBTITLES';
|
||||
|
||||
/**
|
||||
* Action to store received subtitles in history.
|
||||
*/
|
||||
export const STORE_SUBTITLE = 'STORE_SUBTITLE';
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
REMOVE_CACHED_TRANSCRIPT_MESSAGE,
|
||||
REMOVE_TRANSCRIPT_MESSAGE,
|
||||
SET_REQUESTING_SUBTITLES,
|
||||
STORE_SUBTITLE,
|
||||
TOGGLE_REQUESTING_SUBTITLES,
|
||||
UPDATE_TRANSCRIPT_MESSAGE
|
||||
} from './actionTypes';
|
||||
import { ISubtitle } from './types';
|
||||
|
||||
/**
|
||||
* Signals that a transcript has to be removed from the state.
|
||||
@@ -98,3 +100,19 @@ export function setRequestingSubtitles(
|
||||
language
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a received subtitle in the history.
|
||||
*
|
||||
* @param {ISubtitle} subtitle - The subtitle to store.
|
||||
* @returns {{
|
||||
* type: STORE_SUBTITLE,
|
||||
* subtitle: ISubtitle
|
||||
* }}
|
||||
*/
|
||||
export function storeSubtitle(subtitle: ISubtitle) {
|
||||
return {
|
||||
type: STORE_SUBTITLE,
|
||||
subtitle
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,10 +4,21 @@ import { IReduxState } from '../../app/types';
|
||||
import { MEET_FEATURES } from '../../base/jwt/constants';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
|
||||
import { maybeShowPremiumFeatureDialog } from '../../jaas/actions';
|
||||
import { canStartSubtitles } from '../functions.any';
|
||||
import { canStartSubtitles, isCCTabEnabled } from '../functions.any';
|
||||
|
||||
/**
|
||||
* Props interface for the Abstract Closed Caption Button component.
|
||||
*
|
||||
* @interface IAbstractProps
|
||||
* @augments {AbstractButtonProps}
|
||||
*/
|
||||
export interface IAbstractProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* Whether the subtitles tab is enabled in the UI.
|
||||
*/
|
||||
_isCCTabEnabled: boolean;
|
||||
|
||||
_language: string | null;
|
||||
|
||||
/**
|
||||
@@ -109,6 +120,7 @@ export function _abstractMapStateToProps(state: IReduxState, ownProps: IAbstract
|
||||
const { visible = canStartSubtitles(state) } = ownProps;
|
||||
|
||||
return {
|
||||
_isCCTabEnabled: isCCTabEnabled(state),
|
||||
_requestingSubtitles,
|
||||
_language,
|
||||
visible
|
||||
|
||||
@@ -3,11 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../app/types';
|
||||
import {
|
||||
TRANSLATION_LANGUAGES,
|
||||
TRANSLATION_LANGUAGES_HEAD
|
||||
} from '../../base/i18n/i18next';
|
||||
import { setRequestingSubtitles } from '../actions.any';
|
||||
import { getAvailableSubtitlesLanguages } from '../functions.any';
|
||||
|
||||
|
||||
export interface IAbstractLanguageSelectorDialogProps {
|
||||
@@ -30,40 +27,30 @@ export interface IAbstractLanguageSelectorDialogProps {
|
||||
const AbstractLanguageSelectorDialog = (Component: ComponentType<IAbstractLanguageSelectorDialogProps>) => () => {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const noLanguageLabel = 'transcribing.subtitlesOff';
|
||||
|
||||
const language = useSelector((state: IReduxState) => state['features/subtitles']._language);
|
||||
const subtitles = language ?? noLanguageLabel;
|
||||
|
||||
const transcription = useSelector((state: IReduxState) => state['features/base/config'].transcription);
|
||||
const translationLanguagesHead = transcription?.translationLanguagesHead ?? TRANSLATION_LANGUAGES_HEAD;
|
||||
const languagesHead = translationLanguagesHead?.map((lang: string) => `translation-languages:${lang}`);
|
||||
// The value for the selected language contains "translation-languages:" prefix.
|
||||
const selectedLanguage = language?.replace('translation-languages:', '');
|
||||
const languageCodes = useSelector((state: IReduxState) => getAvailableSubtitlesLanguages(state, selectedLanguage));
|
||||
|
||||
// The off and the head languages are always on the top of the list. But once you are selecting
|
||||
// a language from the translationLanguages, that language is moved under the fixedItems list,
|
||||
// until a new languages is selected. FixedItems keep their positions.
|
||||
const fixedItems = [ noLanguageLabel, ...languagesHead ];
|
||||
const translationLanguages = transcription?.translationLanguages ?? TRANSLATION_LANGUAGES;
|
||||
const languages = translationLanguages
|
||||
.map((lang: string) => `translation-languages:${lang}`)
|
||||
.filter((lang: string) => !(lang === subtitles || languagesHead?.includes(lang)));
|
||||
const listItems = (fixedItems?.includes(subtitles)
|
||||
? [ ...fixedItems, ...languages ]
|
||||
: [ ...fixedItems, subtitles, ...languages ])
|
||||
const noLanguageLabel = 'transcribing.subtitlesOff';
|
||||
const selected = language ?? noLanguageLabel;
|
||||
const items = [ noLanguageLabel, ...languageCodes.map((lang: string) => `translation-languages:${lang}`) ];
|
||||
const listItems = items
|
||||
.map((lang, index) => {
|
||||
return {
|
||||
id: lang + index,
|
||||
lang,
|
||||
selected: lang === subtitles
|
||||
selected: lang === selected
|
||||
};
|
||||
});
|
||||
|
||||
const onLanguageSelected = useCallback((value: string) => {
|
||||
const selectedLanguage = value === noLanguageLabel ? null : value;
|
||||
const enabled = Boolean(selectedLanguage);
|
||||
const _selectedLanguage = value === noLanguageLabel ? null : value;
|
||||
const enabled = Boolean(_selectedLanguage);
|
||||
const displaySubtitles = enabled;
|
||||
|
||||
dispatch(setRequestingSubtitles(enabled, displaySubtitles, selectedLanguage));
|
||||
dispatch(setRequestingSubtitles(enabled, displaySubtitles, _selectedLanguage));
|
||||
}, [ language ]);
|
||||
|
||||
return (
|
||||
@@ -72,7 +59,7 @@ const AbstractLanguageSelectorDialog = (Component: ComponentType<IAbstractLangua
|
||||
language = { language }
|
||||
listItems = { listItems }
|
||||
onLanguageSelected = { onLanguageSelected }
|
||||
subtitles = { subtitles }
|
||||
subtitles = { selected }
|
||||
t = { t } />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,37 +2,91 @@ import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconSubtitles } from '../../../base/icons/svg';
|
||||
import { openCCPanel } from '../../../chat/actions.any';
|
||||
import { toggleLanguageSelectorDialog } from '../../actions.web';
|
||||
import { canStartSubtitles, isCCTabEnabled } from '../../functions.any';
|
||||
import {
|
||||
AbstractClosedCaptionButton,
|
||||
IAbstractProps,
|
||||
_abstractMapStateToProps
|
||||
} from '../AbstractClosedCaptionButton';
|
||||
import { IReduxState } from '../../../app/types';
|
||||
|
||||
/**
|
||||
* A button which starts/stops the transcriptions.
|
||||
*/
|
||||
class ClosedCaptionButton
|
||||
extends AbstractClosedCaptionButton {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.cc';
|
||||
override icon = IconSubtitles;
|
||||
override tooltip = 'transcribing.ccButtonTooltip';
|
||||
override label = 'toolbar.startSubtitles';
|
||||
override labelProps = {
|
||||
language: this.props.t(this.props._language ?? 'transcribing.subtitlesOff'),
|
||||
languages: this.props.t(this.props.languages ?? ''),
|
||||
languagesHead: this.props.t(this.props.languagesHead ?? '')
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the current button label based on the CC tab state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
override _getLabel() {
|
||||
const { _isCCTabEnabled } = this.props;
|
||||
|
||||
return _isCCTabEnabled ? 'toolbar.closedCaptions' : 'toolbar.startSubtitles';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the accessibility label for the button.
|
||||
*
|
||||
* @returns {string} Accessibility label.
|
||||
*/
|
||||
override _getAccessibilityLabel() {
|
||||
const { _isCCTabEnabled } = this.props;
|
||||
|
||||
return _isCCTabEnabled ? 'toolbar.accessibilityLabel.closedCaptions' : 'toolbar.accessibilityLabel.cc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tooltip text based on the CC tab state.
|
||||
*
|
||||
* @returns {string} The tooltip text.
|
||||
*/
|
||||
override _getTooltip() {
|
||||
const { _isCCTabEnabled } = this.props;
|
||||
|
||||
return _isCCTabEnabled ? 'transcribing.openClosedCaptions' : 'transcribing.ccButtonTooltip';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle language selection dialog.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClickOpenLanguageSelector() {
|
||||
const { dispatch } = this.props;
|
||||
const { dispatch, _isCCTabEnabled } = this.props;
|
||||
|
||||
dispatch(toggleLanguageSelectorDialog());
|
||||
if (_isCCTabEnabled) {
|
||||
dispatch(openCCPanel());
|
||||
} else {
|
||||
dispatch(toggleLanguageSelectorDialog());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_abstractMapStateToProps)(ClosedCaptionButton));
|
||||
/**
|
||||
* Maps redux state to component props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @param {Object} ownProps - The component's own props.
|
||||
* @returns {Object} Mapped props for the component.
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState, ownProps: IAbstractProps) {
|
||||
const { visible = canStartSubtitles(state) || isCCTabEnabled(state) } = ownProps;
|
||||
|
||||
return _abstractMapStateToProps(state, {
|
||||
...ownProps,
|
||||
visible
|
||||
});
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(ClosedCaptionButton));
|
||||
|
||||
104
react/features/subtitles/components/web/LanguageSelector.tsx
Normal file
104
react/features/subtitles/components/web/LanguageSelector.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { ChangeEvent, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { withPixelLineHeight } from '../../../base/styles/functions.web';
|
||||
import Select from '../../../base/ui/components/web/Select';
|
||||
import { setRequestingSubtitles } from '../../actions.any';
|
||||
import { getAvailableSubtitlesLanguages } from '../../functions.any';
|
||||
|
||||
/**
|
||||
* The styles for the LanguageSelector component.
|
||||
*
|
||||
* @param {Theme} theme - The MUI theme.
|
||||
* @returns {Object} The styles object.
|
||||
*/
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(2),
|
||||
gap: theme.spacing(2)
|
||||
},
|
||||
select: {
|
||||
flex: 1,
|
||||
minWidth: 200
|
||||
},
|
||||
label: {
|
||||
...withPixelLineHeight(theme.typography.bodyShortRegular),
|
||||
color: theme.palette.text01,
|
||||
whiteSpace: 'nowrap'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that renders a language selection dropdown.
|
||||
* Uses the same language options as LanguageSelectorDialog and
|
||||
* updates the subtitles language preference in Redux.
|
||||
*
|
||||
* @param {IProps} props - The component props.
|
||||
* @returns {JSX.Element} - The rendered component.
|
||||
*/
|
||||
function LanguageSelector() {
|
||||
const { t } = useTranslation();
|
||||
const { classes } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const selectedLanguage = useSelector((state: IReduxState) => state['features/subtitles']._language);
|
||||
const languageCodes = useSelector((state: IReduxState) => getAvailableSubtitlesLanguages(
|
||||
state,
|
||||
selectedLanguage?.replace('translation-languages:', '')
|
||||
));
|
||||
|
||||
/**
|
||||
* Maps available languages to Select component options format.
|
||||
*
|
||||
* @type {Array<{value: string, label: string}>}
|
||||
*/
|
||||
const languages = [ 'transcribing.original', ...languageCodes.map(lang => `translation-languages:${lang}`) ]
|
||||
.map(lang => {
|
||||
return {
|
||||
value: lang,
|
||||
label: t(lang)
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles language selection changes.
|
||||
* Dispatches the setRequestingSubtitles action with the new language.
|
||||
*
|
||||
* @param {string} value - The selected language code.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onLanguageChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
|
||||
let { value }: { value?: string | null; } = e.target;
|
||||
|
||||
if (value === 'transcribing.original') {
|
||||
value = null;
|
||||
}
|
||||
dispatch(setRequestingSubtitles(true, true, value));
|
||||
|
||||
if (value !== null) {
|
||||
value = value.replace('translation-languages:', '');
|
||||
}
|
||||
}, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<span className = { classes.label }>
|
||||
{t('transcribing.translateTo')}:
|
||||
</span>
|
||||
<Select
|
||||
className = { classes.select }
|
||||
id = 'subtitles-language-select'
|
||||
onChange = { onLanguageChange }
|
||||
options = { languages }
|
||||
value = { selectedLanguage || 'transcribing.original' } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSelector;
|
||||
@@ -1,4 +1,7 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { IStateful } from '../base/app/types';
|
||||
import { TRANSLATION_LANGUAGES, TRANSLATION_LANGUAGES_HEAD } from '../base/i18n/i18next';
|
||||
import { toState } from '../base/redux/functions';
|
||||
import { canAddTranscriber, isTranscribing } from '../transcribing/functions';
|
||||
|
||||
/**
|
||||
@@ -10,3 +13,59 @@ import { canAddTranscriber, isTranscribing } from '../transcribing/functions';
|
||||
export function canStartSubtitles(state: IReduxState) {
|
||||
return canAddTranscriber(state) || isTranscribing(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of available subtitles languages. The list consists of head languages (fixed items that stay on
|
||||
* top) followed by the rest of available translation languages.
|
||||
*
|
||||
* @param {IStateful} stateful - The stateful object containing the redux state.
|
||||
* @param {string} [selectedLanguage] - Optional language code of currently selected language. If provided and not in
|
||||
* regular translation languages, it will be added after head languages.
|
||||
* @returns {Array<string>} - Array of language codes. Includes both head languages and regular translation languages.
|
||||
*/
|
||||
export function getAvailableSubtitlesLanguages(stateful: IStateful, selectedLanguage?: string | null) {
|
||||
const state = toState(stateful);
|
||||
const { transcription } = state['features/base/config'];
|
||||
|
||||
const translationLanguagesHead = transcription?.translationLanguagesHead ?? TRANSLATION_LANGUAGES_HEAD;
|
||||
const translationLanguages
|
||||
= (transcription?.translationLanguages ?? TRANSLATION_LANGUAGES)
|
||||
.filter((lang: string) => !translationLanguagesHead?.includes(lang) && lang !== selectedLanguage);
|
||||
const isSelectedLanguageNotIncluded = Boolean(
|
||||
selectedLanguage
|
||||
&& !translationLanguages.includes(selectedLanguage)
|
||||
&& !translationLanguagesHead.includes(selectedLanguage));
|
||||
|
||||
return [
|
||||
...translationLanguagesHead,
|
||||
|
||||
// selectedLanguage is redundant but otherwise TS complains about null elements in the array.
|
||||
...isSelectedLanguageNotIncluded && selectedLanguage ? [ selectedLanguage ] : [],
|
||||
...translationLanguages
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Determines if closed captions are enabled.
|
||||
*
|
||||
* @param {IReduxState} state - The Redux state object.
|
||||
* @returns {boolean} A boolean indicating whether closed captions are enabled.
|
||||
*/
|
||||
export function areClosedCaptionsEnabled(state: IReduxState) {
|
||||
const { transcription } = state['features/base/config'];
|
||||
|
||||
return !transcription?.disableClosedCaptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the subtitles tab should be enabled in the UI.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean} - True if the subtitles tab should be enabled.
|
||||
*/
|
||||
export function isCCTabEnabled(state: IReduxState) {
|
||||
const { showSubtitlesOnStage = false } = state['features/base/settings'];
|
||||
|
||||
return areClosedCaptionsEnabled(state) && !showSubtitlesOnStage;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../app/types';
|
||||
|
||||
import ClosedCaptionButton from './components/web/ClosedCaptionButton';
|
||||
import { canStartSubtitles } from './functions.any';
|
||||
import { areClosedCaptionsEnabled, canStartSubtitles } from './functions.any';
|
||||
|
||||
const cc = {
|
||||
key: 'closedcaptions',
|
||||
@@ -12,12 +14,18 @@ const cc = {
|
||||
/**
|
||||
* A hook that returns the CC button if it is enabled and undefined otherwise.
|
||||
*
|
||||
* @returns {Object | undefined}
|
||||
* @returns {Object | undefined}
|
||||
*/
|
||||
export function useClosedCaptionButton() {
|
||||
const isStartSubtitlesButtonVisible = useSelector(canStartSubtitles);
|
||||
const { showSubtitlesOnStage = false } = useSelector((state: IReduxState) => state['features/base/settings']);
|
||||
const _areClosedCaptionsEnabled = useSelector(areClosedCaptionsEnabled);
|
||||
|
||||
if (isStartSubtitlesButtonVisible) {
|
||||
if (!_areClosedCaptionsEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isStartSubtitlesButtonVisible || !showSubtitlesOnStage) {
|
||||
return cc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,13 @@ import {
|
||||
removeCachedTranscriptMessage,
|
||||
removeTranscriptMessage,
|
||||
setRequestingSubtitles,
|
||||
storeSubtitle,
|
||||
updateTranscriptMessage
|
||||
} from './actions.any';
|
||||
import { notifyTranscriptionChunkReceived } from './functions';
|
||||
import { areClosedCaptionsEnabled, isCCTabEnabled } from './functions.any';
|
||||
import logger from './logger';
|
||||
import { ITranscriptMessage } from './types';
|
||||
|
||||
import { ISubtitle, ITranscriptMessage } from './types';
|
||||
|
||||
/**
|
||||
* The type of json-message which indicates that json carries a
|
||||
@@ -122,11 +123,7 @@ function _endpointMessageReceived(store: IStore, next: Function, action: AnyActi
|
||||
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
const language
|
||||
= state['features/base/conference'].conference
|
||||
?.getLocalParticipantProperty(P_NAME_TRANSLATION_LANGUAGE);
|
||||
const { dumpTranscript, skipInterimTranscriptions } = state['features/base/config'].testing ?? {};
|
||||
|
||||
const _areClosedCaptionsEnabled = areClosedCaptionsEnabled(store.getState());
|
||||
const transcriptMessageID = json.message_id;
|
||||
const { name, id, avatar_url: avatarUrl } = json.participant;
|
||||
const participant = {
|
||||
@@ -134,25 +131,57 @@ function _endpointMessageReceived(store: IStore, next: Function, action: AnyActi
|
||||
id,
|
||||
name
|
||||
};
|
||||
const { timestamp } = json;
|
||||
const participantId = participant.id;
|
||||
|
||||
// Handle transcript messages
|
||||
const language = state['features/base/conference'].conference
|
||||
?.getLocalParticipantProperty(P_NAME_TRANSLATION_LANGUAGE);
|
||||
const { dumpTranscript, skipInterimTranscriptions } = state['features/base/config'].testing ?? {};
|
||||
|
||||
let newTranscriptMessage: ITranscriptMessage | undefined;
|
||||
|
||||
if (json.type === JSON_TYPE_TRANSLATION_RESULT && json.language === language) {
|
||||
// Displays final results in the target language if translation is
|
||||
// enabled.
|
||||
newTranscriptMessage = {
|
||||
clearTimeOut: undefined,
|
||||
final: json.text?.trim(),
|
||||
participant
|
||||
};
|
||||
if (json.type === JSON_TYPE_TRANSLATION_RESULT) {
|
||||
if (!_areClosedCaptionsEnabled) {
|
||||
// If closed captions are not enabled, bail out.
|
||||
return next(action);
|
||||
}
|
||||
|
||||
const translation = json.text?.trim();
|
||||
|
||||
if (isCCTabEnabled(state)) {
|
||||
dispatch(storeSubtitle({
|
||||
participantId,
|
||||
text: translation,
|
||||
language: json.language,
|
||||
interim: false,
|
||||
isTranscription: false,
|
||||
timestamp,
|
||||
id: transcriptMessageID
|
||||
}));
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
if (json.language === language) {
|
||||
// Displays final results in the target language if translation is
|
||||
// enabled.
|
||||
newTranscriptMessage = {
|
||||
clearTimeOut: undefined,
|
||||
final: json.text?.trim(),
|
||||
participant
|
||||
};
|
||||
}
|
||||
} else if (json.type === JSON_TYPE_TRANSCRIPTION_RESULT) {
|
||||
const isInterim = json.is_interim;
|
||||
|
||||
// Displays interim and final results without any translation if
|
||||
// translations are disabled.
|
||||
|
||||
const { text } = json.transcript[0];
|
||||
|
||||
// First, notify the external API.
|
||||
if (!(json.is_interim && skipInterimTranscriptions)) {
|
||||
if (!(isInterim && skipInterimTranscriptions)) {
|
||||
const txt: any = {};
|
||||
|
||||
if (!json.is_interim) {
|
||||
@@ -192,6 +221,27 @@ function _endpointMessageReceived(store: IStore, next: Function, action: AnyActi
|
||||
}
|
||||
}
|
||||
|
||||
if (!_areClosedCaptionsEnabled) {
|
||||
// If closed captions are not enabled, bail out.
|
||||
return next(action);
|
||||
}
|
||||
|
||||
const subtitle: ISubtitle = {
|
||||
id: transcriptMessageID,
|
||||
participantId,
|
||||
language: json.language,
|
||||
text,
|
||||
interim: isInterim,
|
||||
timestamp,
|
||||
isTranscription: true
|
||||
};
|
||||
|
||||
if (isCCTabEnabled(state)) {
|
||||
dispatch(storeSubtitle(subtitle));
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
// If the user is not requesting transcriptions just bail.
|
||||
// Regex to filter out all possible country codes after language code:
|
||||
// this should catch all notations like 'en-GB' 'en_GB' and 'enGB'
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
REMOVE_CACHED_TRANSCRIPT_MESSAGE,
|
||||
REMOVE_TRANSCRIPT_MESSAGE,
|
||||
SET_REQUESTING_SUBTITLES,
|
||||
STORE_SUBTITLE,
|
||||
TOGGLE_REQUESTING_SUBTITLES,
|
||||
UPDATE_TRANSCRIPT_MESSAGE
|
||||
} from './actionTypes';
|
||||
import { ITranscriptMessage } from './types';
|
||||
import { ISubtitle, ITranscriptMessage } from './types';
|
||||
|
||||
/**
|
||||
* Default State for 'features/transcription' feature.
|
||||
@@ -18,7 +19,9 @@ const defaultState = {
|
||||
_displaySubtitles: false,
|
||||
_transcriptMessages: new Map(),
|
||||
_requestingSubtitles: false,
|
||||
_language: null
|
||||
_language: null,
|
||||
messages: [],
|
||||
subtitlesHistory: []
|
||||
};
|
||||
|
||||
export interface ISubtitlesState {
|
||||
@@ -27,6 +30,8 @@ export interface ISubtitlesState {
|
||||
_language: string | null;
|
||||
_requestingSubtitles: boolean;
|
||||
_transcriptMessages: Map<string, ITranscriptMessage>;
|
||||
messages: ITranscriptMessage[];
|
||||
subtitlesHistory: Array<ISubtitle>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +64,30 @@ ReducerRegistry.register<ISubtitlesState>('features/subtitles', (
|
||||
...state,
|
||||
...defaultState
|
||||
};
|
||||
case STORE_SUBTITLE: {
|
||||
const existingIndex = state.subtitlesHistory.findIndex(
|
||||
subtitle => subtitle.id === action.subtitle.id
|
||||
);
|
||||
|
||||
if (existingIndex >= 0 && state.subtitlesHistory[existingIndex].interim) {
|
||||
const newHistory = [ ...state.subtitlesHistory ];
|
||||
|
||||
newHistory[existingIndex] = action.subtitle;
|
||||
|
||||
return {
|
||||
...state,
|
||||
subtitlesHistory: newHistory
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
subtitlesHistory: [
|
||||
...state.subtitlesHistory,
|
||||
action.subtitle
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { IGroupableMessage } from '../base/util/messageGrouping';
|
||||
|
||||
export interface ITranscriptMessage {
|
||||
clearTimeOut?: number;
|
||||
final?: string;
|
||||
@@ -9,3 +11,13 @@ export interface ITranscriptMessage {
|
||||
stable?: string;
|
||||
unstable?: string;
|
||||
}
|
||||
|
||||
export interface ISubtitle extends IGroupableMessage {
|
||||
id: string;
|
||||
interim?: boolean;
|
||||
isTranscription?: boolean;
|
||||
language?: string;
|
||||
participantId: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
SET_TOOLBOX_VISIBLE,
|
||||
TOGGLE_TOOLBOX_VISIBLE
|
||||
} from './actionTypes';
|
||||
import { IMainToolbarButtonThresholds } from './types';
|
||||
import { DUMMY_10_BUTTONS_THRESHOLD_VALUE, DUMMY_9_BUTTONS_THRESHOLD_VALUE } from './constants';
|
||||
import { IMainToolbarButtonThresholds, IMainToolbarButtonThresholdsUnfiltered } from './types';
|
||||
|
||||
/**
|
||||
* Enables/disables the toolbox.
|
||||
@@ -127,7 +128,7 @@ export function setShiftUp(shiftUp: boolean) {
|
||||
* @param {IMainToolbarButtonThresholds} thresholds - Thresholds for screen size and visible main toolbar buttons.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function setMainToolbarThresholds(thresholds: IMainToolbarButtonThresholds) {
|
||||
export function setMainToolbarThresholds(thresholds: IMainToolbarButtonThresholdsUnfiltered) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const { mainToolbarButtons } = getState()['features/base/config'];
|
||||
|
||||
@@ -149,12 +150,27 @@ export function setMainToolbarThresholds(thresholds: IMainToolbarButtonThreshold
|
||||
});
|
||||
|
||||
thresholds.forEach(({ width, order }) => {
|
||||
let finalOrder = mainToolbarButtonsLengthMap.get(order.length);
|
||||
let numberOfButtons = 0;
|
||||
|
||||
if (Array.isArray(order)) {
|
||||
numberOfButtons = order.length;
|
||||
} else if (order === DUMMY_9_BUTTONS_THRESHOLD_VALUE) {
|
||||
numberOfButtons = 9;
|
||||
} else if (order === DUMMY_10_BUTTONS_THRESHOLD_VALUE) {
|
||||
numberOfButtons = 10;
|
||||
} else { // Unexpected value. Ignore it.
|
||||
return;
|
||||
}
|
||||
|
||||
let finalOrder = mainToolbarButtonsLengthMap.get(numberOfButtons);
|
||||
|
||||
if (finalOrder) {
|
||||
orderIsChanged = true;
|
||||
} else {
|
||||
} else if (Array.isArray(order)) {
|
||||
finalOrder = order;
|
||||
} else {
|
||||
// Ignore dummy (symbol) values.
|
||||
return;
|
||||
}
|
||||
|
||||
mainToolbarButtonsThresholds.push({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isMobileBrowser } from '../../../base/environment/utils';
|
||||
import { getLocalParticipant, isLocalParticipantModerator } from '../../../base/participants/functions';
|
||||
import ContextMenu from '../../../base/ui/components/web/ContextMenu';
|
||||
import { isReactionsButtonEnabled, shouldDisplayReactionsButtons } from '../../../reactions/functions.web';
|
||||
import { isCCTabEnabled } from '../../../subtitles/functions.any';
|
||||
import { isTranscribing } from '../../../transcribing/functions';
|
||||
import {
|
||||
setHangupMenuVisible,
|
||||
@@ -91,9 +92,10 @@ export default function Toolbox({
|
||||
const isDialogVisible = useSelector((state: IReduxState) => Boolean(state['features/base/dialog'].component));
|
||||
const localParticipant = useSelector(getLocalParticipant);
|
||||
const transcribing = useSelector(isTranscribing);
|
||||
const _isCCTabEnabled = useSelector(isCCTabEnabled);
|
||||
|
||||
// Do not convert to selector, it returns new array and will cause re-rendering of toolbox on every action.
|
||||
const jwtDisabledButtons = getJwtDisabledButtons(transcribing, localParticipant?.features);
|
||||
const jwtDisabledButtons = getJwtDisabledButtons(transcribing, _isCCTabEnabled, localParticipant?.features);
|
||||
|
||||
const reactionsButtonEnabled = useSelector(isReactionsButtonEnabled);
|
||||
const _shouldDisplayReactionsButtons = useSelector(shouldDisplayReactionsButtons);
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import { NativeToolbarButton, ToolbarButton } from './types';
|
||||
|
||||
/**
|
||||
* Dummy toolbar threschold value for 9 buttons. It is used as a placeholder in THRESHOLDS that would work only when
|
||||
* this value is overiden.
|
||||
*/
|
||||
export const DUMMY_9_BUTTONS_THRESHOLD_VALUE = Symbol('9_BUTTONS_THRESHOLD_VALUE');
|
||||
|
||||
/**
|
||||
* Dummy toolbar threschold value for 10 buttons. It is used as a placeholder in THRESHOLDS that would work only when
|
||||
* this value is overiden.
|
||||
*/
|
||||
export const DUMMY_10_BUTTONS_THRESHOLD_VALUE = Symbol('10_BUTTONS_THRESHOLD_VALUE');
|
||||
|
||||
/**
|
||||
* Thresholds for displaying toolbox buttons.
|
||||
*/
|
||||
export const THRESHOLDS = [
|
||||
|
||||
// This entry won't be used unless the order is overridden trough the mainToolbarButtons config prop.
|
||||
{
|
||||
width: 675,
|
||||
order: DUMMY_10_BUTTONS_THRESHOLD_VALUE
|
||||
},
|
||||
|
||||
// This entry won't be used unless the order is overridden trough the mainToolbarButtons config prop.
|
||||
{
|
||||
width: 625,
|
||||
order: DUMMY_9_BUTTONS_THRESHOLD_VALUE
|
||||
},
|
||||
{
|
||||
width: 565,
|
||||
order: [ 'microphone', 'camera', 'desktop', 'chat', 'raisehand', 'reactions', 'participants-pane', 'tileview' ]
|
||||
|
||||
@@ -27,11 +27,13 @@ export function isAudioMuteButtonDisabled(state: IReduxState) {
|
||||
* This function is stateless as it returns a new array and may cause re-rendering.
|
||||
*
|
||||
* @param {boolean} isTranscribing - Whether there is currently a transcriber in the meeting.
|
||||
* @param {boolean} isCCTabEnabled - Whether the closed captions tab is enabled.
|
||||
* @param {ILocalParticipant} localParticipantFeatures - The features of the local participant.
|
||||
* @returns {string[]} - The disabled by jwt buttons array.
|
||||
*/
|
||||
export function getJwtDisabledButtons(
|
||||
isTranscribing: boolean,
|
||||
isCCTabEnabled: boolean,
|
||||
localParticipantFeatures?: IParticipantFeatures) {
|
||||
const acc = [];
|
||||
|
||||
@@ -43,7 +45,7 @@ export function getJwtDisabledButtons(
|
||||
acc.push('livestreaming');
|
||||
}
|
||||
|
||||
if (!isTranscribing && !isJwtFeatureEnabledStateless({
|
||||
if (!isTranscribing && !isCCTabEnabled && !isJwtFeatureEnabledStateless({
|
||||
localParticipantFeatures,
|
||||
feature: 'transcription',
|
||||
ifNotInFeatures: false
|
||||
|
||||
@@ -66,8 +66,8 @@ export function isVideoMuteButtonDisabled(state: IReduxState) {
|
||||
* @param {IGetVisibleButtonsParams} params - The parameters needed to extract the visible buttons.
|
||||
* @returns {Object} - The visible buttons arrays .
|
||||
*/
|
||||
export function getVisibleNativeButtons({ allButtons, clientWidth, mainToolbarButtonsThresholds, toolbarButtons
|
||||
}: IGetVisibleNativeButtonsParams) {
|
||||
export function getVisibleNativeButtons(
|
||||
{ allButtons, clientWidth, mainToolbarButtonsThresholds, toolbarButtons }: IGetVisibleNativeButtonsParams) {
|
||||
const filteredButtons = Object.keys(allButtons).filter(key =>
|
||||
typeof key !== 'undefined' // filter invalid buttons that may be coming from config.mainToolbarButtons override
|
||||
&& isButtonEnabled(key, toolbarButtons));
|
||||
|
||||
@@ -21,6 +21,15 @@ import {
|
||||
import { NATIVE_THRESHOLDS, THRESHOLDS } from './constants';
|
||||
import { IMainToolbarButtonThresholds, NOTIFY_CLICK_MODE } from './types';
|
||||
|
||||
/**
|
||||
* Array of thresholds for the main toolbar buttons that will inlude only the usable entries from THRESHOLDS array.
|
||||
*
|
||||
* Note: THRESHOLDS array includes some dummy values that enables users of the iframe API to override and use.
|
||||
* Note2: Casting is needed because it seems isArray guard is not working well in TS. See:
|
||||
* https://github.com/microsoft/TypeScript/issues/17002.
|
||||
*/
|
||||
const FILTERED_THRESHOLDS = THRESHOLDS.filter(({ order }) => Array.isArray(order)) as IMainToolbarButtonThresholds;
|
||||
|
||||
/**
|
||||
* Initial state of toolbox's part of Redux store.
|
||||
*/
|
||||
@@ -52,7 +61,7 @@ const INITIAL_STATE = {
|
||||
/**
|
||||
* The thresholds for screen size and visible main toolbar buttons.
|
||||
*/
|
||||
mainToolbarButtonsThresholds: navigator.product === 'ReactNative' ? NATIVE_THRESHOLDS : THRESHOLDS,
|
||||
mainToolbarButtonsThresholds: navigator.product === 'ReactNative' ? NATIVE_THRESHOLDS : FILTERED_THRESHOLDS,
|
||||
|
||||
participantMenuButtonsWithNotifyClick: new Map(),
|
||||
|
||||
|
||||
@@ -65,6 +65,11 @@ export type IMainToolbarButtonThresholds = Array<{
|
||||
width: number;
|
||||
}>;
|
||||
|
||||
export type IMainToolbarButtonThresholdsUnfiltered = Array<{
|
||||
order: Array<ToolbarButton | NativeToolbarButton | string> | Symbol;
|
||||
width: number;
|
||||
}>;
|
||||
|
||||
export interface ICustomToolbarButton {
|
||||
Content?: ComponentType<any>;
|
||||
backgroundColor?: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { playSound } from '../base/sounds/actions';
|
||||
import { showNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
|
||||
import { INotificationProps } from '../notifications/types';
|
||||
import { RECORDING_OFF_SOUND_ID, RECORDING_ON_SOUND_ID } from '../recording/constants';
|
||||
import { isLiveStreamingRunning, isRecordingRunning } from '../recording/functions';
|
||||
|
||||
@@ -58,11 +59,13 @@ function maybeEmitRecordingNotification(dispatch: IStore['dispatch'], getState:
|
||||
return;
|
||||
}
|
||||
|
||||
const notifyProps: INotificationProps = {
|
||||
descriptionKey: on ? 'recording.on' : 'recording.off',
|
||||
titleKey: 'dialog.recording'
|
||||
};
|
||||
|
||||
batch(() => {
|
||||
dispatch(showNotification({
|
||||
descriptionKey: on ? 'recording.on' : 'recording.off',
|
||||
titleKey: 'dialog.recording'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
dispatch(showNotification(notifyProps, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
dispatch(playSound(on ? RECORDING_ON_SOUND_ID : RECORDING_OFF_SOUND_ID));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ module:hook("pre-iq/full", function(event)
|
||||
dial:tag("header", {
|
||||
xmlns = "urn:xmpp:rayo:1",
|
||||
name = OUT_INITIATOR_USER_ATTR_NAME,
|
||||
value = user_id });
|
||||
value = tostring(user_id)});
|
||||
dial:up();
|
||||
|
||||
-- Add the initiator group information if it is present
|
||||
@@ -153,7 +153,7 @@ module:hook("pre-iq/full", function(event)
|
||||
dial:tag("header", {
|
||||
xmlns = "urn:xmpp:rayo:1",
|
||||
name = OUT_INITIATOR_GROUP_ATTR_NAME,
|
||||
value = session.jitsi_meet_context_group });
|
||||
value = tostring(session.jitsi_meet_context_group) });
|
||||
dial:up();
|
||||
end
|
||||
end
|
||||
|
||||
@@ -72,7 +72,7 @@ module:hook('jitsi-endpoint-message-received', function(event)
|
||||
|
||||
if string.len(event.raw_message) >= POLL_PAYLOAD_LIMIT then
|
||||
module:log('error', 'Poll payload too large, discarding. Sender: %s to:%s', stanza.attr.from, stanza.attr.to);
|
||||
return nil;
|
||||
return true;
|
||||
end
|
||||
|
||||
if data.type == "new-poll" then
|
||||
@@ -86,7 +86,7 @@ module:hook('jitsi-endpoint-message-received', function(event)
|
||||
|
||||
if room.polls.count >= POLLS_LIMIT then
|
||||
module:log("error", "Too many polls created in %s", room.jid)
|
||||
return
|
||||
return true;
|
||||
end
|
||||
|
||||
if room.polls.by_id[data.pollId] ~= nil then
|
||||
|
||||
@@ -30,9 +30,7 @@ describe('Codec selection', () => {
|
||||
|
||||
// Check if p1 is sending VP9 and p2 is sending VP8 as per their codec preferences.
|
||||
// Except on Firefox because it doesn't support VP9 encode.
|
||||
const majorVersion = parseInt(p1.driver.capabilities.browserVersion || '0', 10);
|
||||
|
||||
if (p1.driver.isFirefox && majorVersion < 136) {
|
||||
if (p1.driver.isFirefox) {
|
||||
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp8())).toBe(true);
|
||||
} else {
|
||||
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp9())).toBe(true);
|
||||
@@ -54,11 +52,11 @@ describe('Codec selection', () => {
|
||||
// Check if media is playing on p3.
|
||||
expect(await p3.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
|
||||
|
||||
// Check if p1 is encoding in VP9, p2 in VP8 and p3 in AV1 as per their codec preferences.
|
||||
// Except on Firefox because it doesn't support AV1/VP9 encode and AV1 decode.
|
||||
const majorVersion = parseInt(p1.driver.capabilities.browserVersion || '0', 10);
|
||||
|
||||
if (p1.driver.isFirefox && majorVersion < 136) {
|
||||
// Check if p1 is encoding in VP9, p2 in VP8 and p3 in AV1 as per their codec preferences.
|
||||
// Except on Firefox because it doesn't support VP9 encode.
|
||||
if (p1.driver.isFirefox) {
|
||||
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp8())).toBe(true);
|
||||
} else {
|
||||
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp9())).toBe(true);
|
||||
@@ -87,9 +85,7 @@ describe('Codec selection', () => {
|
||||
const { p1, p2 } = ctx;
|
||||
|
||||
// Disable this test on Firefox because it doesn't support VP9 encode.
|
||||
const majorVersion = parseInt(p1.driver.capabilities.browserVersion || '0', 10);
|
||||
|
||||
if (p1.driver.isFirefox && majorVersion < 136) {
|
||||
if (p1.driver.isFirefox) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user