Compare commits

...

18 Commits

Author SHA1 Message Date
Дамян Минков
9ebab2c7d0 feat: Visitors promotion (#14119)
* fix: Fixes wrong warning message.

* fix: Detect enables/disables visitors for a room.

* fix: We need customusername in all cases of auto-allow setting.

* feat: Sends promotion-request to all moderators.

* feat(visitors): Implements request promotion.

* feat(visitors): Implements single moderator and vpass cases for moderators.

* fix: Fixes clearing request instances from UI.

* feat: Implements visitors approval for mobile.

* squash: Drops unused and wrong report for auto allow promotion.

* squash: Returns early based on count.

* squash: Moves translation to common key.

* squash: Adds dependencies for useCallback.

* squash: comments.

* squash: Refactor 1 to unify with native.

* squash: Rename some styles.

* squash: Fixes error dew to fewer hooks error.

* squash: Renames VISITOR_PROMOTION_REQUEST_DENIED.

* squash: Fix renaming component.

* squash: Suggestions.
2023-12-14 08:31:58 -06:00
Horatiu Muresan
af4488d1e9 fix(toolbox) prevent toolbox shift up on stage view (#14155) 2023-12-14 12:32:19 +02:00
bgrozev
d9599d31f1 fix: Do not log unknown commands. (#14153)
Events such as "mouse-move", "mouse-leave" and "face-landmark-detected"
reach this code and pollute the logs. It's probably worth investigating
why this is the case and fixing it if necessary, but for now just remove
the log message.
2023-12-13 10:04:48 -08:00
Mihaela Dumitru
d094ac0034 fix(external-api) extend captureLargeVideoScreenshot for screenshare (#14149) 2023-12-13 17:31:44 +02:00
Avram Tudor
c6b7ec7c9c fix(transcript) duplicated namespace (#14151) 2023-12-13 16:15:09 +02:00
Calinteodor
6e35e5b310 feat(call-integration): revert changes related to visitors (#14150)
* feat(mobile/call-integration): removed undefined checks
2023-12-13 15:38:44 +02:00
Jaya Allamsetty
429787f9c8 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v1734.0.0+34ceebd2...v1736.0.0+8bee4514
2023-12-12 16:04:22 -05:00
Дамян Минков
f7995b395f feat: Adds detection of occupants with no connection. (#14146)
* feat: Adds detection of occupants with no connection.

We saw recently two occasions with rooms with participants but no prosody.full_sessions for those participants and when everyone leaves the meeting it never ends.

* squash: Updates counting.
2023-12-12 11:47:43 -06:00
Avram Tudor
72b4c8123a ref(transcriptions): refactor transcriptions api (#14144)
* ref(transcriptions): refactor transcriptions api

* ref(transcriptions): refactor usage of translation label

Extend IFrame API to allow adding a transcriber in the room without the subtitles needing to be visible.
Allow transcription chunk messages to be passed through the IFrame API if a transcriber is present.
Clean-up transcription messages sent through the IFrame API to not include timeout field and possible conflicting states (stable / unstable /final)

* fix linting

* code review: extend api message to match webhook format
2023-12-12 14:36:54 +02:00
Abbas Al-Mansoori
4c6cadea6d fix: lint 2023-12-12 12:04:38 +02:00
Abbas Al-Mansoori
1bc50ea71c feat(rn-sdk): add getRoomsInfo ref callback 2023-12-12 12:04:38 +02:00
Abbas Al-Mansoori
60b5225ffd feat(rn-sdk): add onParticipantLeft event listener 2023-12-12 12:04:38 +02:00
damencho
5fe3685a05 fix: Drops luacheck for modules sourced from prosody-modules.
Only mod_firewall fails for now.
2023-12-11 10:41:34 -06:00
Aaron van Meerten
fbfc0f6c2f task: vendor mod_firewall from prosody plugins
changeset 6696075e26e2
https://hg.prosody.im/prosody-modules/raw-file/6696075e26e2/mod_firewall/mod_firewall.lua
2023-12-11 10:41:34 -06:00
Aaron van Meerten
bbed4be61b task: vendor mod_measure_stanza_counts.lua
changeset 6696075e26e2
https://hg.prosody.im/prosody-modules/raw-file/6696075e26e2/mod_measure_stanza_counts/mod_measure_stanza_counts.lua
2023-12-11 10:41:34 -06:00
Aaron van Meerten
68f954d068 task: vendor mod_debug_traceback.lua
changeset 6696075e26e2
https://hg.prosody.im/prosody-modules/raw-file/6696075e26e2/mod_debug_traceback/mod_debug_traceback.lua
2023-12-11 10:41:34 -06:00
Aaron van Meerten
30144b8707 feat: vendor mod_log_ringbuffer from prosody hg
changeset 6696075e26e2
https://hg.prosody.im/prosody-modules/raw-file/6696075e26e2/mod_log_ringbuffer/mod_log_ringbuffer.lua
2023-12-11 10:41:34 -06:00
Saúl Ibarra Corretgé
dd232f55a9 fix(rn,room-lock) use numeric input for password dialog if appropriate (#14142) 2023-12-11 14:10:31 +01:00
98 changed files with 3266 additions and 398 deletions

View File

@@ -17,7 +17,8 @@ jobs:
- name: Check lua codes
run: |
set -o pipefail && luacheck . | awk -F: '
set -o pipefail && luacheck . \
--exclude-files=resources/prosody-plugins/mod_firewall/mod_firewall.lua | awk -F: '
{
print $0
printf "::warning file=%s,line=%s,col=%s::%s\n", $1, $2, $3, $4

View File

@@ -557,8 +557,6 @@
"youtubeTerms": "شروط خدمة يوتيوب"
},
"lobby": {
"admit": "سمح بالدخول",
"admitAll": "سمح للجميع بالدخول",
"allow": "اسمح",
"backToKnockModeButton": "لا يوجد كلمة مرور، اطلب الإذن بالدخول بدلًا من ذلك.",
"chat": "دردشة",
@@ -593,8 +591,6 @@
"notificationTitle": "غرفة الانتظار",
"passwordField": "أدخل كلمة الدخول إلى المُلتقى",
"passwordJoinButton": "انضم",
"reject": "رفض",
"rejectAll": "رفض الكل",
"title": "غرفة الانتظار",
"toggleLabel": "فعِّل غرفة الانتظار"
},
@@ -720,6 +716,8 @@
},
"participantsPane": {
"actions": {
"admit": "سمح بالدخول",
"admitAll": "سمح للجميع بالدخول",
"allow": "السماح للحاضرين بـ:",
"allowVideo": "السماح بالفيديو",
"askUnmute": "اطلب إعادة الصوت",
@@ -732,6 +730,7 @@
"mute": "كتم الصوت",
"muteAll": "كتم الكل",
"muteEveryoneElse": "كتم صوت الآخرين",
"reject": "رفض",
"stopEveryonesVideo": "أوقف فيديو الجميع",
"stopVideo": "أوقف الفيديو",
"unblockEveryoneMicCamera": "قم بإلغاء حظر ميكروفون وكاميرا الجميع",

View File

@@ -420,8 +420,6 @@
"youtubeTerms": "Условия за ползване на YouTube"
},
"lobby": {
"admit": "Допусни",
"allow": "Разреши",
"backToKnockModeButton": "Заявка за включване без парола",
"dialogTitle": "Режим лоби",
"disableDialogContent": "Режим Лоби е включен. Този решим защитава срещите Ви от случайни посетители. Искате ли да го изключите?",
@@ -450,7 +448,6 @@
"notificationTitle": "Лоби",
"passwordField": "Въведи парола за срещата",
"passwordJoinButton": "Влез",
"reject": "Откажи",
"title": "Лоби",
"toggleLabel": "Включи лоби"
},
@@ -521,6 +518,13 @@
"suboptimalExperienceTitle": "Внимание",
"unmute": "Пускане на микрофона"
},
"participantsPane": {
"actions": {
"admit": "Допусни",
"allow": "Разреши",
"reject": "Откажи"
}
},
"passwordDigitsOnly": "До {{number}} цифри",
"passwordSetRemotely": "зададена от друг участник",
"poweredby": "с подкрепата на",

View File

@@ -561,8 +561,6 @@
"youtubeTerms": "Condicions de servei de YouTube"
},
"lobby": {
"admit": "Admet",
"admitAll": "Admet tothom",
"allow": "Permet",
"backToKnockModeButton": "Demaneu per a unir-vos",
"chat": "Xat",
@@ -597,8 +595,6 @@
"notificationTitle": "Sala d'espera",
"passwordField": "Introduïu la contrasenya de la reunió",
"passwordJoinButton": "Entra",
"reject": "Rebuja",
"rejectAll": "Rebutja-ho tot",
"title": "Sala d'espera",
"toggleLabel": "Activa la sala d'espera"
},
@@ -727,6 +723,8 @@
},
"participantsPane": {
"actions": {
"admit": "Admet",
"admitAll": "Admet tothom",
"allow": "Permet als assistents:",
"allowVideo": "Permet el vídeo",
"askUnmute": "Demanar l'activació el micròfon",
@@ -739,6 +737,7 @@
"mute": "Silenciar",
"muteAll": "Silencia tothom",
"muteEveryoneElse": "Silenciar tothom",
"reject": "Rebuja",
"stopEveryonesVideo": "Atura el vídeo a tothom",
"stopVideo": "Atura el vídeo",
"unblockEveryoneMicCamera": "Desbloquejar el micròfon i la càmera de tothom",

View File

@@ -557,8 +557,6 @@
"youtubeTerms": "Podmínky používání YouTube"
},
"lobby": {
"admit": "",
"admitAll": "",
"allow": "Povolit",
"backToKnockModeButton": "Žádné heslo, místo toho požádat o přijetí",
"chat": "",
@@ -593,7 +591,6 @@
"notificationTitle": "Předsálí",
"passwordField": "Zadejte heslo setkání",
"passwordJoinButton": "Vstoupit",
"reject": "Odmítnout",
"title": "Předsálí",
"toggleLabel": "Zapnout předsálí"
},
@@ -719,22 +716,7 @@
},
"participantsPane": {
"actions": {
"allow": "",
"allowVideo": "",
"askUnmute": "",
"audioModeration": "",
"blockEveryoneMicCamera": "",
"invite": "",
"moreModerationActions": "",
"moreModerationControls": "",
"moreParticipantOptions": "",
"mute": "",
"muteAll": "",
"muteEveryoneElse": "",
"stopEveryonesVideo": "",
"stopVideo": "",
"unblockEveryoneMicCamera": "",
"videoModeration": ""
"reject": "Odmítnout"
},
"close": "",
"header": "",

View File

@@ -636,8 +636,6 @@
"youtubeTerms": "YouTube-Nutzungsbedingungen"
},
"lobby": {
"admit": "Zulassen",
"admitAll": "Alle zulassen",
"backToKnockModeButton": "Kein Passwort, stattdessen Beitritt anfragen",
"chat": "Chat",
"dialogTitle": "Lobbymodus",
@@ -671,8 +669,6 @@
"notificationLobbyEnabled": "{{originParticipantName}} hat die Lobby aktiviert",
"notificationTitle": "Lobby",
"passwordJoinButton": "Beitreten",
"reject": "Ablehnen",
"rejectAll": "Alle ablehnen",
"title": "Lobby",
"toggleLabel": "Lobby aktivieren"
},
@@ -807,6 +803,8 @@
},
"participantsPane": {
"actions": {
"admit": "Zulassen",
"admitAll": "Alle zulassen",
"allow": "Anwesenden erlauben:",
"allowVideo": "Kamera einschalten",
"askUnmute": "Anfragen, Stummschaltung aufzuheben",
@@ -819,6 +817,7 @@
"mute": "Stummschalten",
"muteAll": "Alle stummschalten",
"muteEveryoneElse": "Alle anderen stummschalten",
"reject": "Ablehnen",
"stopEveryonesVideo": "Alle Kameras ausschalten",
"stopVideo": "Kamera ausschalten",
"unblockEveryoneMicCamera": "Kamera und Mikrofon von allen entsperren",

View File

@@ -563,8 +563,6 @@
"youtubeTerms": "wužywaŕske wustawki za youtube"
},
"lobby": {
"admit": "pśizwóliś",
"admitAll": "wšyknym pśizwólenje daś",
"backToKnockModeButton": "mimo kodowego słowa, město togo wó pśistup pšosyś",
"chat": "chat",
"dialogTitle": "lobbyjowy modus",
@@ -598,8 +596,6 @@
"notificationTitle": "lobby",
"passwordField": "kodowe słowo za konferencu zapódaś",
"passwordJoinButton": "pśistupiś",
"reject": "wótpokazaś",
"rejectAll": "wšykne wótpokazaś",
"title": "",
"toggleLabel": "lobby aktiwěrowaś / deaktiwěrowaś"
},
@@ -730,6 +726,8 @@
},
"participantsPane": {
"actions": {
"admit": "pśizwóliś",
"admitAll": "wšyknym pśizwólenje daś",
"allow": "wobźělnikam pšawo daś:",
"allowVideo": "kameru aktiwěrowaś",
"askUnmute": "pšosbu wó anulěrowanje wuśišenja stajiś",
@@ -742,6 +740,7 @@
"mute": "wuśišyś",
"muteAll": "wšyknych wuśišyś",
"muteEveryoneElse": "wšykne druge wuśišyś",
"reject": "wótpokazaś",
"stopEveryonesVideo": "wšykne kamery wušaltowaś",
"stopVideo": "kameru wušaltowaś",
"unblockEveryoneMicCamera": "blokěrowane kamery a mikrofon wšyknych zasej aktiwěrowaś",

View File

@@ -580,8 +580,6 @@
"youtubeTerms": "Όροι υπηρεσιών YouTube"
},
"lobby": {
"admit": "Αποδοχή",
"admitAll": "Αποδοχή όλων",
"backToKnockModeButton": "Αίτημα εισόδου",
"chat": "Συνομιλία",
"dialogTitle": "Λειτουργία υποδοχής",
@@ -615,8 +613,6 @@
"notificationTitle": "Υποδοχή",
"passwordField": "Εισάγετε τον κωδικό σύσκεψης",
"passwordJoinButton": "Συμμετοχή",
"reject": "Απόρριψη",
"rejectAll": "Απόρριψη όλων",
"title": "Υποδοχή",
"toggleLabel": "Ενεργοποίηση υποδοχής"
},
@@ -745,6 +741,8 @@
},
"participantsPane": {
"actions": {
"admit": "Αποδοχή",
"admitAll": "Αποδοχή όλων",
"allow": "Επιτρέψτε στους συμμετέχοντες να:",
"allowVideo": "Επιτρέψτε το βίντεο",
"askUnmute": "Αίτηση για κατάργηση σίγησης",
@@ -757,6 +755,7 @@
"mute": "Σίγηση",
"muteAll": "Σίγηση όλων",
"muteEveryoneElse": "Σίγηση όλων των άλλων",
"reject": "Απόρριψη",
"stopEveryonesVideo": "Διακοπή όλων των βίντεο",
"stopVideo": "Διακοπή του βίντεο",
"unblockEveryoneMicCamera": "Επιτρέψτε τα μικρόφωνα και τις κάμερες όλων",

View File

@@ -561,8 +561,6 @@
"youtubeTerms": "Uzkondiĉoj de YouTube"
},
"lobby": {
"admit": "Akcepti",
"admitAll": "Akcepti ĉion",
"allow": "Permesi",
"backToKnockModeButton": "Petu por aliĝi",
"chat": "Babilejo",
@@ -597,8 +595,6 @@
"notificationTitle": "Atendejo",
"passwordField": "Entajpu pasvorton de la renkontiĝo",
"passwordJoinButton": "Aliĝi",
"reject": "Malakceptu",
"rejectAll": "Malakceptu ĉion",
"title": "Atendejo",
"toggleLabel": "Ŝaltu atendejon"
},
@@ -725,6 +721,8 @@
},
"participantsPane": {
"actions": {
"admit": "Akcepti",
"admitAll": "Akcepti ĉion",
"allow": "Al la partoprenantoj permesi:",
"allowVideo": "Permesi kameraon",
"askUnmute": "Peti malsilentigi",
@@ -737,6 +735,7 @@
"mute": "Silentigi",
"muteAll": "Silentigi ĉiujn",
"muteEveryoneElse": "Silentigi ĉiujn aliajn",
"reject": "Malakceptu",
"stopEveryonesVideo": "Ĉesigu ĉies videaĵon",
"stopVideo": "Ĉesigu la videaĵon",
"unblockEveryoneMicCamera": "Malbloku ĉies mikrofonon kaj kameraon",

View File

@@ -598,8 +598,6 @@
"youtubeTerms": "Términos de servicios de YouTube"
},
"lobby": {
"admit": "Admitir",
"admitAll": "Admitir todo",
"backToKnockModeButton": "No hay contraseña, pide permiso para entrar.",
"chat": "Chat",
"dialogTitle": "Sala de espera",
@@ -633,8 +631,6 @@
"notificationTitle": "Sala de espera",
"passwordField": "Introduce la contraseña de la reunión",
"passwordJoinButton": "Entrar",
"reject": "Rechazar",
"rejectAll": "Rechazar todo",
"title": "Sala de espera",
"toggleLabel": "Activar sala de espera"
},
@@ -768,6 +764,8 @@
},
"participantsPane": {
"actions": {
"admit": "Admitir",
"admitAll": "Admitir todo",
"allow": "Permitir a los asistentes:",
"allowVideo": "Permitir vídeo",
"askUnmute": "Pida que le quiten el silencio",
@@ -780,6 +778,7 @@
"mute": "Silenciar",
"muteAll": "Silenciar a todos",
"muteEveryoneElse": "Silenciar al resto",
"reject": "Rechazar",
"stopEveryonesVideo": "Detener el vídeo de todos",
"stopVideo": "Detener el vídeo",
"unblockEveryoneMicCamera": "Desbloquear el micrófono y la cámara de todos",

View File

@@ -521,8 +521,6 @@
"youtubeTerms": "Términos de servicios de YouTube"
},
"lobby": {
"admit": "Admitir",
"admitAll": "Admitir todo",
"allow": "permitir",
"backToKnockModeButton": "No hay contraseña, pide permiso para entrar.",
"dialogTitle": "Sala de espera",
@@ -553,8 +551,6 @@
"notificationTitle": "Sala de espera",
"passwordField": "Introduce la contraseña de la reunión",
"passwordJoinButton": "Entrar",
"reject": "Rechazar",
"rejectAll": "Rechazar todo",
"title": "Sala de espera",
"toggleLabel": "Activar sala de espera"
},
@@ -652,6 +648,8 @@
},
"participantsPane": {
"actions": {
"admit": "Admitir",
"admitAll": "Admitir todo",
"allow": "Permitir a los asistentes:",
"allowVideo": "Permitir vídeo",
"askUnmute": "Pida que le quiten el silencio",
@@ -661,6 +659,7 @@
"mute": "Silenciar",
"muteAll": "Silenciar a todos los demás",
"muteEveryoneElse": "Silenciar al resto",
"reject": "Rechazar",
"stopEveryonesVideo": "Detener el vídeo de todos",
"stopVideo": "Detener el vídeo",
"unblockEveryoneMicCamera": "Desbloquear el micrófono y la cámara de todos",

View File

@@ -463,8 +463,6 @@
"youtubeTerms": "YouTuberen erabilpen baldintzak"
},
"lobby": {
"admit": "Onartu",
"admitAll": "Onartu guztiak",
"allow": "Baimendu",
"backToKnockModeButton": "Ez du pasahitza erabili, baina sartzea eskatu du",
"dialogTitle": "Itxaron-gela modua",
@@ -494,7 +492,6 @@
"notificationTitle": "Itxaron-gela",
"passwordField": "Idatzi bileraren pasahitza",
"passwordJoinButton": "Sartu",
"reject": "Baztertu",
"title": "Itxaron-gela",
"toggleLabel": "Itxaron-gela aktibatu"
},
@@ -576,8 +573,11 @@
},
"participantsPane": {
"actions": {
"admit": "Onartu",
"admitAll": "Onartu guztiak",
"invite": "Gonbidatu norbait",
"muteAll": "Ixilarazi guztiak",
"reject": "Baztertu",
"stopVideo": "Gelditu bideoa"
},
"close": "Itxi",

View File

@@ -606,8 +606,6 @@
"youtubeTerms": "شرایط خدمات یوتیوب"
},
"lobby": {
"admit": "پذیرفتن",
"admitAll": "پذیرفتن همه",
"backToKnockModeButton": "درخواست برای پیوستن",
"chat": "گپ",
"dialogTitle": "حالت اتاق انتظار",
@@ -641,8 +639,6 @@
"notificationTitle": "اتاق انتظار",
"passwordField": "گذرواژهٔ جلسه را وارد کنید",
"passwordJoinButton": "پیوستن",
"reject": "ردکردن",
"rejectAll": "ردکردن همه",
"title": "اتاق انتظار",
"toggleLabel": "فعال‌کردن اتاق انتظار"
},
@@ -776,6 +772,8 @@
},
"participantsPane": {
"actions": {
"admit": "پذیرفتن",
"admitAll": "پذیرفتن همه",
"allow": "به حاضران اجازه دهید:",
"allowVideo": "اجازهٔ ویدیو",
"askUnmute": "درخواست وصل‌کردن صدا",
@@ -788,6 +786,7 @@
"mute": "بی‌صداکردن",
"muteAll": "بی‌صداکردن همه",
"muteEveryoneElse": "بی‌صداکردن بقیه افراد",
"reject": "ردکردن",
"stopEveryonesVideo": "توقف ویدیوی همه",
"stopVideo": "توقف ویدیو",
"unblockEveryoneMicCamera": "رفع مسدودی میکروفون و دوربین همه",

View File

@@ -584,8 +584,6 @@
"youtubeTerms": "Conditions d'utilisation de YouTube"
},
"lobby": {
"admit": "Accepter",
"admitAll": "Tout accepter",
"backToKnockModeButton": "Aucun mot de passe, demander à rejoindre plutôt",
"chat": "Chat",
"dialogTitle": "Mode salle d'attente",
@@ -619,8 +617,6 @@
"notificationTitle": "Salle d'attente",
"passwordField": "Veuillez saisir le mot de passe de la réunion",
"passwordJoinButton": "Rejoindre",
"reject": "Refuser",
"rejectAll": "Refuser tout",
"title": "Salle d'attente",
"toggleLabel": "Activer la salle d'attente"
},
@@ -751,6 +747,8 @@
},
"participantsPane": {
"actions": {
"admit": "Accepter",
"admitAll": "Tout accepter",
"allow": "Autoriser les participants à:",
"allowVideo": "permettre la vidéo",
"askUnmute": "Demander de réactiver le micro",
@@ -763,6 +761,7 @@
"mute": "Couper le micro",
"muteAll": "Couper le micro de tout le monde",
"muteEveryoneElse": "Couper le micro de tous les autres",
"reject": "Refuser",
"stopEveryonesVideo": "Couper toutes les caméras",
"stopVideo": "Couper la vidéo",
"unblockEveryoneMicCamera": "Débloquer tous les micros et caméras",

View File

@@ -482,7 +482,6 @@
"notificationTitle": "लॉबी",
"passwordField": "मीटिंग पासवर्ड दर्ज करें",
"passwordJoinButton": "Join",
"reject": "अस्वीकार",
"title": "लॉबी",
"toggleLabel": "लॉबी सक्षम करें"
},
@@ -559,6 +558,11 @@
"videoMutedRemotelyDescription": "You can always turn it on again.",
"videoMutedRemotelyTitle": "आपका कैमरा {{participantDisplayName}}द्वारा अक्षम कर दिया गया है!"
},
"participantsPane": {
"actions": {
"reject": "अस्वीकार"
}
},
"passwordDigitsOnly": "Up to {{number}} digits",
"passwordSetRemotely": "दूसरे प्रतिभागी द्वारा निर्धारित",
"poweredby": "powered by",

View File

@@ -561,8 +561,6 @@
"youtubeTerms": "Uvjeti YouTube usluge"
},
"lobby": {
"admit": "Prihvati",
"admitAll": "Prihvati sve",
"allow": "Dopusti",
"backToKnockModeButton": "Zatraži pridruživanje",
"chat": "Chat",
@@ -597,8 +595,6 @@
"notificationTitle": "Predvorje",
"passwordField": "Upiši lozinku sastanka",
"passwordJoinButton": "Pridruži se",
"reject": "Odbij",
"rejectAll": "Odbij sve",
"title": "Predvorje",
"toggleLabel": "Uključi predvorje"
},
@@ -725,6 +721,8 @@
},
"participantsPane": {
"actions": {
"admit": "Prihvati",
"admitAll": "Prihvati sve",
"allow": "Dozvoli sudionicima da:",
"allowVideo": "Dozvole video",
"askUnmute": "Zatraže isključivanje zvuka",
@@ -737,6 +735,7 @@
"mute": "Isključe zvuk",
"muteAll": "Isključe zvuk svih sudionika",
"muteEveryoneElse": "Isključe zvuk svih drugih",
"reject": "Odbij",
"stopEveryonesVideo": "Prekinu videa svih",
"stopVideo": "Prekinu video",
"unblockEveryoneMicCamera": "Deblokiraju mikrofone i kamere svih sudionika",

View File

@@ -551,8 +551,6 @@
"youtubeTerms": "wuměnjenja wužiwanja na YouTube"
},
"lobby": {
"admit": "přizwolić",
"admitAll": "wšitko přizwolić",
"allow": "přiwzać",
"backToKnockModeButton": "žane hesło, město toho wo přistup prosyć",
"chat": "chat",
@@ -587,8 +585,6 @@
"notificationTitle": "lobby",
"passwordField": "konferencne hesło zapodać",
"passwordJoinButton": "přistupić",
"reject": "wotpokazać",
"rejectAll": "wšitko wotpokazać",
"title": "lobby",
"toggleLabel": "lobby aktiwěrować"
},
@@ -710,6 +706,8 @@
},
"participantsPane": {
"actions": {
"admit": "přizwolić",
"admitAll": "wšitko přizwolić",
"allow": "přitomnym dowolić",
"allowVideo": "kameru zaswěčić",
"askUnmute": "wo wotstajenje šaltowanja na němosć prosyć",
@@ -722,6 +720,7 @@
"mute": "něme šaltować",
"muteAll": "wšěch němych šaltować",
"muteEveryoneElse": "wšěch druhich němych šaltować",
"reject": "wotpokazać",
"stopEveryonesVideo": "wšitke kamery hasnyć",
"stopVideo": "kameru hasnyć",
"unblockEveryoneMicCamera": "kameru a mikrofon wšěch wočinić",

View File

@@ -462,8 +462,6 @@
"youtubeTerms": "YouTube szolgáltatási feltételek"
},
"lobby": {
"admit": "Engedélyezés",
"admitAll": "Mindet engedélyez",
"allow": "Engedélyez",
"backToKnockModeButton": "Csatlakozási kérelem küldése",
"chat": "Chat",
@@ -495,8 +493,6 @@
"notificationTitle": "Lobby",
"passwordField": "Adja meg az értekezlet jelszavát",
"passwordJoinButton": "Csatlakozás",
"reject": "Elutasít",
"rejectAll": "Mindet elutasít",
"toggleLabel": "Lobby engedélyezése"
},
"localRecording": {
@@ -579,6 +575,8 @@
},
"participantsPane": {
"actions": {
"admit": "Engedélyezés",
"admitAll": "Mindet engedélyez",
"allow": "Engedélyezés a résztvevőknek, hogy:",
"allowVideo": "Videó engedélyezése",
"askUnmute": "Kérje a némítás feloldását",
@@ -591,6 +589,7 @@
"mute": "Némítás",
"muteAll": "Mindenkit elnémít",
"muteEveryoneElse": "Mute everyone else",
"reject": "Elutasít",
"stopEveryonesVideo": "Mindenki videójának leállítása",
"stopVideo": "Videó leállítása",
"unblockEveryoneMicCamera": "Unblock everyone's mic and camera",

View File

@@ -561,8 +561,6 @@
"youtubeTerms": "Condizioni di utilizzo di YouTube"
},
"lobby": {
"admit": "Ammetti",
"admitAll": "Ammetti tutti",
"allow": "Autorizza",
"backToKnockModeButton": "Nessuna password, richiedi l'accesso",
"chat": "Conversazione",
@@ -597,8 +595,6 @@
"notificationTitle": "Sala d'attesa",
"passwordField": "Inserisci la password della riunione",
"passwordJoinButton": "Entra",
"reject": "Respingi",
"rejectAll": "Respingi tutti",
"title": "Sala d'attesa",
"toggleLabel": "Attiva sala d'attesa"
},
@@ -725,6 +721,8 @@
},
"participantsPane": {
"actions": {
"admit": "Ammetti",
"admitAll": "Ammetti tutti",
"allow": "Permetti ai partecipanti di:",
"allowVideo": "Autorizza video",
"askUnmute": "Chiedi di accendere microfono",
@@ -737,6 +735,7 @@
"mute": "Silenzia",
"muteAll": "Silenzia tutti",
"muteEveryoneElse": "Silenzia tutti gli altri",
"reject": "Respingi",
"stopEveryonesVideo": "Ferma il video di tutti",
"stopVideo": "Ferma il video",
"unblockEveryoneMicCamera": "Sblocca audio e video a tutti",

View File

@@ -525,8 +525,6 @@
"youtubeTerms": "YouTube サービス利用規約"
},
"lobby": {
"admit": "許可",
"admitAll": "全員許可",
"allow": "許可",
"backToKnockModeButton": "参加を依頼",
"dialogTitle": "ロビーモード",
@@ -557,8 +555,6 @@
"notificationTitle": "ロビー",
"passwordField": "ミーティングパスワードを入力してください",
"passwordJoinButton": "参加",
"reject": "却下",
"rejectAll": "全員却下",
"title": "ロビー",
"toggleLabel": "ロビーを有効"
},
@@ -671,6 +667,8 @@
},
"participantsPane": {
"actions": {
"admit": "許可",
"admitAll": "全員許可",
"allow": "参加者に次のことを許可:",
"allowVideo": "ビデオを許可",
"askUnmute": "ミュート解除を依頼",
@@ -683,6 +681,7 @@
"mute": "ミュート",
"muteAll": "全員をミュート",
"muteEveryoneElse": "他のすべての人をミュート",
"reject": "却下",
"stopEveryonesVideo": "全員のビデオを停止",
"stopVideo": "ビデオを停止",
"unblockEveryoneMicCamera": "全員のマイクとビデオのブロックを解除",

View File

@@ -498,8 +498,6 @@
"youtubeTerms": "Tiwtilin n yimeẓla n Youtube"
},
"lobby": {
"admit": "Steεref",
"admitAll": "Steεref s kullec",
"allow": "Sireg",
"backToKnockModeButton": "Ulac awal uffir, suter attekki deg ubdil-is",
"dialogTitle": "Askar Lobby",
@@ -530,8 +528,6 @@
"notificationTitle": "Taxxamt n uraǧu",
"passwordField": "Sekcem awal uffir n temlilit",
"passwordJoinButton": "Semlil",
"reject": "Agi",
"rejectAll": "Agi akk",
"title": "Taxxamt n uraǧu",
"toggleLabel": "Rmed Lobby"
},
@@ -628,6 +624,8 @@
},
"participantsPane": {
"actions": {
"admit": "Steεref",
"admitAll": "Steεref s kullec",
"allow": "Sireg i yimttekkiyen ad:",
"allowVideo": "Sireg tavidyut",
"askUnmute": "Suter tririt n ṣṣut",
@@ -637,6 +635,7 @@
"mute": "Asusam",
"muteAll": "Sgugem meṛṛa",
"muteEveryoneElse": "Sgugem-iten i meṛṛa",
"reject": "Agi",
"stopEveryonesVideo": "Seḥbes tavidyut n yal yiwen",
"stopVideo": "Seḥbes tavidyut n Youtube",
"unblockEveryoneMicCamera": "Serreḥ i usawaḍ d tkamiṛat n yal yiwen",

View File

@@ -642,8 +642,6 @@
"youtubeTerms": "YouTube pakalpojumu sniegšanas noteikumi"
},
"lobby": {
"admit": "Apstiprināt",
"admitAll": "Apstiprināt visus",
"backToKnockModeButton": "Pajautāt pievienoties",
"chat": "Tērzēšana",
"dialogTitle": "Vestibila režīms",
@@ -677,8 +675,6 @@
"notificationLobbyEnabled": "Vestibilu iespējoja {{originParticipantName}}",
"notificationTitle": "Vestibils",
"passwordJoinButton": "Pievienoties",
"reject": "Noraidīt",
"rejectAll": "Noraidīt visus",
"title": "Vestibils",
"toggleLabel": "Iespējot vestibilu"
},
@@ -816,6 +812,8 @@
},
"participantsPane": {
"actions": {
"admit": "Apstiprināt",
"admitAll": "Apstiprināt visus",
"allow": "Atļaut dalībniekiem:",
"allowVideo": "Atļaut video",
"askUnmute": "Lūgt ieslēgt skaņu",
@@ -829,6 +827,7 @@
"mute": "Apklusināt",
"muteAll": "Apklusināt visus",
"muteEveryoneElse": "Apklusināt pārējos",
"reject": "Noraidīt",
"stopEveryonesVideo": "Izslēgt visiem video",
"stopVideo": "Izslēgt video",
"unblockEveryoneMicCamera": "Atbloķēt visiem mikrofonu un kameru",

View File

@@ -464,7 +464,6 @@
"notificationTitle": "ലോബി",
"passwordField": "മീറ്റിംഗ് പാസ്‌വേഡ് നൽകുക",
"passwordJoinButton": "ചേരുക",
"reject": "നിരസിക്കുക",
"title": "ലോബി",
"toggleLabel": "ലോബി പ്രവർത്തനക്ഷമമാക്കുക"
},
@@ -539,6 +538,11 @@
"suboptimalExperienceTitle": "ബ്രൗസർ മുന്നറിയിപ്പ്",
"unmute": "അൺമ്യൂട്ട്"
},
"participantsPane": {
"actions": {
"reject": "നിരസിക്കുക"
}
},
"passwordDigitsOnly": "{{number}} അക്കങ്ങൾ വരെ",
"passwordSetRemotely": "മറ്റൊരു പങ്കാളി സജ്ജമാക്കിയത്",
"poweredby": "powered by",

View File

@@ -586,8 +586,6 @@
"youtubeTerms": "YouTube үйлчилгээний нөхцөл"
},
"lobby": {
"admit": "Ok",
"admitAll": "Бүгдийг зөвшөөр",
"backToKnockModeButton": "Зөвшөөрөл хүсэх",
"chat": "Зурвас",
"dialogTitle": "Лобби горим",
@@ -621,8 +619,6 @@
"notificationTitle": "Лобби",
"passwordField": "Нууц үгээ оруулна уу",
"passwordJoinButton": "Оролцох",
"reject": "Татгалзах",
"rejectAll": "Бүгдийг татгалзуулах",
"title": "Лобби",
"toggleLabel": "Лобби идэвхижүүлэх"
},
@@ -755,6 +751,8 @@
},
"participantsPane": {
"actions": {
"admit": "Ok",
"admitAll": "Бүгдийг зөвшөөр",
"allow": "Оролцогчийг зөвшөөрөх:",
"allowVideo": "Дүрс зөвшөөрөх",
"askUnmute": "Дуугаа нээхийг хүсэх",
@@ -767,6 +765,7 @@
"mute": "Дуугүй болгох",
"muteAll": "Бүгдийг дуугүй болгох",
"muteEveryoneElse": "Бүгдийг дуугүй болгох",
"reject": "Татгалзах",
"stopEveryonesVideo": "Бүгдийн дүрсийг хаах",
"stopVideo": "Дүрс хаах",
"unblockEveryoneMicCamera": "Бүх хүний микрофон, камерыг нээх",

View File

@@ -486,8 +486,6 @@
"youtubeTerms": "Servicevoorwaarden YouTube"
},
"lobby": {
"admit": "Toelaten",
"admitAll": "Allen toelaten",
"allow": "Toestaan",
"backToKnockModeButton": "Geen wachtwoord, vraag om deel te mogen nemen",
"dialogTitle": "Lobby-modus",
@@ -519,8 +517,6 @@
"notificationTitle": "Lobby",
"passwordField": "Voer wachtwoord voor vergadering in",
"passwordJoinButton": "Deelnemen",
"reject": "Afwijzen",
"rejectAll": "Allen afwijzen",
"title": "Lobby",
"toggleLabel": "Lobby inschakelen"
},
@@ -624,6 +620,8 @@
},
"participantsPane": {
"actions": {
"admit": "Toelaten",
"admitAll": "Allen toelaten",
"allow": "Sta deelnemers toe:",
"allowVideo": "Video toestaan",
"askUnmute": "Vragen om dempen op te heffen",
@@ -636,6 +634,7 @@
"mute": "Dempen",
"muteAll": "Allen dempen",
"muteEveryoneElse": "Alle anderen dempen",
"reject": "Afwijzen",
"stopEveryonesVideo": "Camera's van iedereen uitzetten",
"stopVideo": "Camera uitzetten",
"unblockEveryoneMicCamera": "Deblokkeer microfoon en camera van allen",

View File

@@ -524,8 +524,6 @@
"youtubeTerms": "Condicions dutilizacion de YouTube"
},
"lobby": {
"admit": "Acceptar",
"admitAll": "Tot acceptar",
"allow": "Autorizar",
"backToKnockModeButton": "Cap de senhal, demandar a participar a la plaça",
"dialogTitle": "Mòde sala d'espèra",
@@ -556,8 +554,6 @@
"notificationTitle": "Sala d'espèra",
"passwordField": "Picatz lo senhal de la conferéncia",
"passwordJoinButton": "Rejónher",
"reject": "Regetar",
"rejectAll": "Tot regetar",
"title": "Sala d'espèra",
"toggleLabel": "Activar la sala d'espèra"
},
@@ -670,6 +666,8 @@
},
"participantsPane": {
"actions": {
"admit": "Acceptar",
"admitAll": "Tot acceptar",
"allow": "Permetre als convidats de:",
"allowVideo": "Autorizar la vidèo",
"askUnmute": "Demandar a restablir lo son",
@@ -682,6 +680,7 @@
"mute": "Amudir",
"muteAll": "Amudir tot lo monde",
"muteEveryoneElse": "Amudir tot los demai",
"reject": "Regetar",
"stopEveryonesVideo": "Arrestar la vidèo de tot lo monde",
"stopVideo": "Arrestar la vidèo",
"unblockEveryoneMicCamera": "Desblocar lo microfòn e la camèra de tot lo monde",

View File

@@ -586,8 +586,6 @@
"youtubeTerms": "Warunki użytkowania YouTube"
},
"lobby": {
"admit": "Pozwól",
"admitAll": "Pozwól wszystkim",
"backToKnockModeButton": "Brak hasła, poproś o dołączenie",
"chat": "Chat",
"dialogTitle": "Lobby",
@@ -621,8 +619,6 @@
"notificationTitle": "Lobby",
"passwordField": "Wprowadź hasło",
"passwordJoinButton": "Dołącz",
"reject": "Odrzuć",
"rejectAll": "Odrzuć wszystkich",
"title": "Lobby",
"toggleLabel": "Włącz / Wyłącz lobby"
},
@@ -756,6 +752,8 @@
},
"participantsPane": {
"actions": {
"admit": "Pozwól",
"admitAll": "Pozwól wszystkim",
"allow": "Zezwól uczestnikom na:",
"allowVideo": "Zezwól na wideo",
"askUnmute": "Poproś o wyłączenie wyciszenia",
@@ -768,6 +766,7 @@
"mute": "Wycisz",
"muteAll": "Wycisz wszystkich",
"muteEveryoneElse": "Wycisz pozostałych",
"reject": "Odrzuć",
"stopEveryonesVideo": "Wyłącz wszystkie kamery",
"stopVideo": "Wyłącz kamerę",
"unblockEveryoneMicCamera": "Odblokuj wszystkim kamerę i mikrofon",

View File

@@ -641,8 +641,6 @@
"youtubeTerms": "Termos de serviços do YouTube"
},
"lobby": {
"admit": "Aceitar",
"admitAll": "Aceitar todos",
"backToKnockModeButton": "Peça para aderir",
"chat": "Chat",
"dialogTitle": "Modo sala de espera",
@@ -676,8 +674,6 @@
"notificationLobbyEnabled": "A sala de espera foi activada por {{originParticipantName}}",
"notificationTitle": "Sala de espera",
"passwordJoinButton": "Solicitar",
"reject": "Rejeitar",
"rejectAll": "Rejeitar todos",
"title": "Sala de espera",
"toggleLabel": "Ativar sala de espera"
},
@@ -815,6 +811,8 @@
},
"participantsPane": {
"actions": {
"admit": "Aceitar",
"admitAll": "Aceitar todos",
"allow": "Permitir aos participantes:",
"allowVideo": "Permitir vídeo",
"askUnmute": "Pedir para ligar o som",
@@ -828,6 +826,7 @@
"mute": "Silenciar",
"muteAll": "Silenciar todos",
"muteEveryoneElse": "Silenciar todos os outros",
"reject": "Rejeitar",
"stopEveryonesVideo": "Desligar a câmara de todos",
"stopVideo": "Desligar a câmara",
"unblockEveryoneMicCamera": "Desbloquear o microfone e a câmara de todos",

View File

@@ -642,8 +642,6 @@
"youtubeTerms": "Termos de serviços do YouTube"
},
"lobby": {
"admit": "Aceitar",
"admitAll": "Aceitar todos",
"backToKnockModeButton": "Sem senha, peça para se juntar",
"chat": "Chat",
"dialogTitle": "Modo sala de espera",
@@ -677,8 +675,6 @@
"notificationLobbyEnabled": "Sala de espera foi habilitada por {{originParticipantName}}",
"notificationTitle": "Sala de espera",
"passwordJoinButton": "Solicitar",
"reject": "Rejeitar",
"rejectAll": "Rejeitar todos",
"title": "Sala de espera",
"toggleLabel": "Habilitar sala de espera"
},
@@ -816,6 +812,8 @@
},
"participantsPane": {
"actions": {
"admit": "Aceitar",
"admitAll": "Aceitar todos",
"allow": "Permitir aos participantes:",
"allowVideo": "Permitir vídeo",
"askUnmute": "Pedir para ativar som",
@@ -829,6 +827,7 @@
"mute": "Silenciar",
"muteAll": "Silenciar todos",
"muteEveryoneElse": "Silenciar todos os demais",
"reject": "Rejeitar",
"stopEveryonesVideo": "Parar vídeo de todos",
"stopVideo": "Parar vídeo",
"unblockEveryoneMicCamera": "Desbloquear microfone e câmera de todos",

View File

@@ -557,8 +557,6 @@
"youtubeTerms": "Условия использования YouTube"
},
"lobby": {
"admit": "Признать",
"admitAll": "Признать все",
"backToKnockModeButton": "Попросить присоединиться",
"chat": "Чат",
"dialogTitle": "Режим лобби",
@@ -592,8 +590,6 @@
"notificationTitle": "Лобби",
"passwordField": "Введите пароль встречи",
"passwordJoinButton": "Присоединиться",
"reject": "Отказать",
"rejectAll": "Отказать всем",
"title": "Лобби",
"toggleLabel": "Включить лобби"
},
@@ -720,6 +716,8 @@
},
"participantsPane": {
"actions": {
"admit": "Признать",
"admitAll": "Признать все",
"allow": "Разрешить",
"allowVideo": "Разрешить видео",
"askUnmute": "Попросить разрешение включить микрофон",
@@ -732,6 +730,7 @@
"mute": "Выключить звук",
"muteAll": "Выключить звук у всех",
"muteEveryoneElse": "Выключить микрофон у остальных",
"reject": "Отказать",
"stopEveryonesVideo": "Выключить у всех камеру",
"stopVideo": "Остановить видео",
"unblockEveryoneMicCamera": "Разблокировать у всех микрофон и камеру",

View File

@@ -561,8 +561,6 @@
"youtubeTerms": "Cunditziones de servìtziu de YouTube"
},
"lobby": {
"admit": "Ammite",
"admitAll": "Ammite totu",
"allow": "Permite",
"backToKnockModeButton": "Pedi de intrare",
"chat": "Tzarrada",
@@ -597,8 +595,6 @@
"notificationTitle": "Aposentu de abetu",
"passwordField": "Inserta sa crae de sa riunione",
"passwordJoinButton": "Aderi",
"reject": "Refuda",
"rejectAll": "Refuda totu",
"title": "Aposentu de abetu",
"toggleLabel": "Ativa s'aposentu de abetu"
},
@@ -727,6 +723,8 @@
},
"participantsPane": {
"actions": {
"admit": "Ammite",
"admitAll": "Ammite totu",
"allow": "Permite a is partetzipantes:",
"allowVideo": "Permite vìdeu",
"askUnmute": "Pedi de ativare su micròfonu",
@@ -739,6 +737,7 @@
"mute": "A sa muda",
"muteAll": "Totu a sa muda",
"muteEveryoneElse": "Pone totus a sa muda",
"reject": "Refuda",
"stopEveryonesVideo": "Istuda su vìdeu de totu is partetzipantes",
"stopVideo": "Firma su vìdeu",
"unblockEveryoneMicCamera": "Isbloca su micròfonu e sa càmera de totu is partetzipantes",

View File

@@ -466,7 +466,6 @@
"notificationTitle": "Čakáreň",
"passwordField": "Zadajte heslo do konferencie",
"passwordJoinButton": "Vstúpiť",
"reject": "Odmietnuť",
"title": "Čakáreň",
"toggleLabel": "Zapnúť čakáreň"
},
@@ -541,6 +540,11 @@
"suboptimalExperienceTitle": "Prehliadačové varovanie",
"unmute": "Zapnúť mikrofón"
},
"participantsPane": {
"actions": {
"reject": "Odmietnuť"
}
},
"passwordDigitsOnly": "až {{number}} číslic",
"passwordSetRemotely": "nastavené iným účastníkom",
"poweredby": "založené na",

View File

@@ -499,8 +499,6 @@
"youtubeTerms": "Pogoji uporabe YouTube"
},
"lobby": {
"admit": "Sprejmi",
"admitAll": "Sprejmi vse",
"allow": "Dovoli",
"backToKnockModeButton": "Prosi za dostop",
"dialogTitle": "Način predsobe",
@@ -531,8 +529,6 @@
"notificationTitle": "Predsoba",
"passwordField": "Vnesite geslo sestanka",
"passwordJoinButton": "Pridruži se",
"reject": "Zavrni",
"rejectAll": "Zavrni vse",
"title": "Predsoba",
"toggleLabel": "Omogoči predsobo"
},
@@ -629,6 +625,8 @@
},
"participantsPane": {
"actions": {
"admit": "Sprejmi",
"admitAll": "Sprejmi vse",
"allow": "Udeleženci si lahko:",
"allowVideo": "Dovoli video",
"askUnmute": "Prosi za vklop mikrofona",
@@ -638,6 +636,7 @@
"mute": "Izklopi zvok",
"muteAll": "Izklopi zvok vsem",
"muteEveryoneElse": "Izklopi zvok vsem ostalim",
"reject": "Zavrni",
"stopEveryonesVideo": "Izklopi video vsem ostalim",
"stopVideo": "Izklopi video",
"unblockEveryoneMicCamera": "Dovoli zvok in video vsem udeležencem",

View File

@@ -582,8 +582,6 @@
"youtubeTerms": "Kushte shërbimi YouTube"
},
"lobby": {
"admit": "Pranoje",
"admitAll": "Pranoji të tërë",
"backToKnockModeButton": "Kërkoji të marrë pjesë",
"chat": "Fjalosje",
"dialogTitle": "Mënyra holl",
@@ -617,8 +615,6 @@
"notificationTitle": "Holl",
"passwordField": "Jepni fjalëkalim takimi",
"passwordJoinButton": "Hyni",
"reject": "Hidhe poshtë",
"rejectAll": "Hidhi poshtë të tërë",
"title": "Holl",
"toggleLabel": "Aktivizoni hollin"
},
@@ -749,6 +745,8 @@
},
"participantsPane": {
"actions": {
"admit": "Pranoje",
"admitAll": "Pranoji të tërë",
"allow": "Lejoju pjesëmarrësve të:",
"allowVideo": "Çaktivizoni videon",
"askUnmute": "Kërkoni heqje heshtimi",
@@ -761,6 +759,7 @@
"mute": "Heshtoje",
"muteAll": "Heshtoji të tërë",
"muteEveryoneElse": "Heshto gjithkënd tjetër",
"reject": "Hidhe poshtë",
"stopEveryonesVideo": "Ndal videon e gjithkujt",
"stopVideo": "Ndale videon",
"unblockEveryoneMicCamera": "Zhblloko mikrofonin dhe kamerën e gjithkujt",

View File

@@ -587,8 +587,6 @@
"youtubeTerms": "Tjänstevillkor för YouTube"
},
"lobby": {
"admit": "Godkänn",
"admitAll": "Godkänn alla",
"backToKnockModeButton": "Tillbaka till väntrum",
"chat": "Chatt",
"dialogTitle": "Väntrum",
@@ -622,8 +620,6 @@
"notificationTitle": "Väntrum",
"passwordField": "Ange möteslösenord",
"passwordJoinButton": "Anslut",
"reject": "Avvisa",
"rejectAll": "Avvisa alla",
"title": "Lobby",
"toggleLabel": "Aktivera väntrum"
},
@@ -757,6 +753,8 @@
},
"participantsPane": {
"actions": {
"admit": "Godkänn",
"admitAll": "Godkänn alla",
"allow": "Låt deltagarna:",
"allowVideo": "Tillåt kamera",
"askUnmute": "Be om att aktivera ljud",
@@ -769,6 +767,7 @@
"mute": "Stäng av ljud",
"muteAll": "Stäng av allt ljud",
"muteEveryoneElse": "Inaktivera ljud för alla deltagare",
"reject": "Avvisa",
"stopEveryonesVideo": "Inaktivera allas video",
"stopVideo": "Inaktivera video",
"unblockEveryoneMicCamera": "Aktivera allas mikrofon och kamera",

View File

@@ -455,8 +455,6 @@
"youtubeTerms": "యూట్యూబ్ సేవా నియమాలు"
},
"lobby": {
"admit": "అనుమతించు",
"allow": "అనుమతించు",
"backToKnockModeButton": "సంకేతపదం లేదు, చేర్చుకోమని అడుగు",
"dialogTitle": "Lobby mode",
"disableDialogContent": "Lobby mode is currently enabled. This feature ensures that unwanted participants can't join your meeting. Do you want to disable it?",
@@ -485,7 +483,6 @@
"notificationTitle": "Lobby",
"passwordField": "సమావేశం సంకేతపదం ఇవ్వండి",
"passwordJoinButton": "చేరు",
"reject": "నిరాకరించు",
"title": "Lobby",
"toggleLabel": "Enable lobby"
},
@@ -566,8 +563,11 @@
},
"participantsPane": {
"actions": {
"admit": "అనుమతించు",
"allow": "అనుమతించు",
"invite": "ప్రజలను ఆహ్వానించు",
"muteAll": "అందరినీ మౌనించు",
"reject": "నిరాకరించు",
"stopVideo": "వీడియో ఆపివేయి"
},
"headings": {

View File

@@ -561,8 +561,6 @@
"youtubeTerms": "YouTube hizmet şartları"
},
"lobby": {
"admit": "Kabul et",
"admitAll": "Hepsini kabul et",
"allow": "İzin ver",
"backToKnockModeButton": "Parola yok, bunun yerine katılmayı isteyin",
"chat": "Sohbet et",
@@ -597,8 +595,6 @@
"notificationTitle": "Lobi",
"passwordField": "Toplantı parolasını giriniz",
"passwordJoinButton": "Katıl",
"reject": "Reddet",
"rejectAll": "Hepsini reddet",
"title": "Lobi",
"toggleLabel": "Lobiyi etkinleştir"
},
@@ -725,6 +721,8 @@
},
"participantsPane": {
"actions": {
"admit": "Kabul et",
"admitAll": "Hepsini kabul et",
"allow": "Katılımcıların şunları yapmasına izin ver:",
"allowVideo": "Video'ya izin ver",
"askUnmute": "Sesi açmayı iste",
@@ -737,6 +735,7 @@
"mute": "Sessize al",
"muteAll": "Herkesi sessize al",
"muteEveryoneElse": "Diğer herkesi sessize al",
"reject": "Reddet",
"stopEveryonesVideo": "Herkesin videosunu durdur",
"stopVideo": "Video'yu durdur",
"unblockEveryoneMicCamera": "Herkesin mikrofonunun ve kamerasının engellemesini kaldır",

View File

@@ -584,8 +584,6 @@
"youtubeTerms": "Умови надання послуг YouTube"
},
"lobby": {
"admit": "Допустити",
"admitAll": "Допустити всіх",
"backToKnockModeButton": "Запитати дозволу",
"chat": "Чат",
"dialogTitle": "Приймальна",
@@ -619,8 +617,6 @@
"notificationTitle": "Приймальна",
"passwordField": "Ввести пароль зустрічі",
"passwordJoinButton": "Приєднатися",
"reject": "Відмовити",
"rejectAll": "Відмовити всім",
"title": "Приймальна",
"toggleLabel": "Увімкнути приймальну"
},
@@ -753,6 +749,8 @@
},
"participantsPane": {
"actions": {
"admit": "Допустити",
"admitAll": "Допустити всіх",
"allow": "Дозволити учасникам:",
"allowVideo": "Розблокувати камеру",
"askUnmute": "Надати слово",
@@ -765,6 +763,7 @@
"mute": "Вимкнути мікрофон",
"muteAll": "Вимкнути мікрофони всім",
"muteEveryoneElse": "Вимкнути мікрофони всім іншим",
"reject": "Відмовити",
"stopEveryonesVideo": "Вимкнути камери всім",
"stopVideo": "Вимкнути камеру",
"unblockEveryoneMicCamera": "Розблокувати всім мікрофон і камеру",

View File

@@ -627,8 +627,6 @@
"youtubeTerms": "YouTube服务条款"
},
"lobby": {
"admit": "同意",
"admitAll": "同意全部",
"backToKnockModeButton": "请求加入",
"chat": "聊天",
"dialogTitle": "大厅模式",
@@ -662,8 +660,6 @@
"notificationTitle": "大厅",
"passwordField": "输入会议密码",
"passwordJoinButton": "加入",
"reject": "拒绝",
"rejectAll": "拒绝全部",
"title": "大厅",
"toggleLabel": "开启大厅模式"
},
@@ -798,6 +794,8 @@
},
"participantsPane": {
"actions": {
"admit": "同意",
"admitAll": "同意全部",
"allow": "允许参会者:",
"allowVideo": "允许视频",
"askUnmute": "请求解除静音",
@@ -810,6 +808,7 @@
"mute": "静音",
"muteAll": "全体静音",
"muteEveryoneElse": "全体静音",
"reject": "拒绝",
"stopEveryonesVideo": "禁用所有人视频",
"stopVideo": "禁用视频",
"unblockEveryoneMicCamera": "允许所有人的麦克风和摄像头",

View File

@@ -641,8 +641,6 @@
"youtubeTerms": "YouTube 服務條款"
},
"lobby": {
"admit": "準許",
"admitAll": "準許所有人",
"backToKnockModeButton": "請求加入",
"chat": "聊天",
"dialogTitle": "大廳模式",
@@ -676,8 +674,6 @@
"notificationTitle": "大廳",
"passwordField": "輸入會議密碼",
"passwordJoinButton": "加入",
"reject": "拒絕",
"rejectAll": "拒絕所有人",
"title": "大廳",
"toggleLabel": "啟用大廳模式"
},
@@ -815,6 +811,8 @@
},
"participantsPane": {
"actions": {
"admit": "準許",
"admitAll": "準許所有人",
"allow": "允許與會者能夠:",
"allowVideo": "允許視訊",
"askUnmute": "要求解除靜音",
@@ -828,6 +826,7 @@
"mute": "靜音",
"muteAll": "靜音所有人",
"muteEveryoneElse": "靜音其他人",
"reject": "拒絕",
"stopEveryonesVideo": "停用所有人的視訊",
"stopVideo": "停用視訊",
"unblockEveryoneMicCamera": "解除封鎖所有人的麥克風及網路攝影機",

View File

@@ -642,8 +642,6 @@
"youtubeTerms": "YouTube terms of services"
},
"lobby": {
"admit": "Admit",
"admitAll": "Admit all",
"backToKnockModeButton": "Ask to join",
"chat": "Chat",
"dialogTitle": "Lobby mode",
@@ -677,8 +675,6 @@
"notificationLobbyEnabled": "Lobby has been enabled by {{originParticipantName}}",
"notificationTitle": "Lobby",
"passwordJoinButton": "Join",
"reject": "Reject",
"rejectAll": "Reject all",
"title": "Lobby",
"toggleLabel": "Enable lobby"
},
@@ -816,6 +812,8 @@
},
"participantsPane": {
"actions": {
"admit": "Admit",
"admitAll": "Admit all",
"allow": "Allow attendees to:",
"allowVideo": "Allow video",
"askUnmute": "Ask to unmute",
@@ -829,6 +827,7 @@
"mute": "Mute",
"muteAll": "Mute all",
"muteEveryoneElse": "Mute everyone else",
"reject": "Reject",
"stopEveryonesVideo": "Stop everyone's video",
"stopVideo": "Stop video",
"unblockEveryoneMicCamera": "Unblock everyone's mic and camera",
@@ -838,7 +837,8 @@
"headings": {
"lobby": "Lobby ({{count}})",
"participantsList": "Meeting participants ({{count}})",
"visitors": "Visitors ({{count}})",
"visitorRequests": " (requests {{count}})",
"visitors": "Visitors {{count}}",
"waitingLobby": "Waiting in lobby ({{count}})"
},
"search": "Search participants",

View File

@@ -466,8 +466,8 @@ function initCommands() {
'toggle-subtitles': () => {
APP.store.dispatch(toggleRequestingSubtitles());
},
'set-subtitles': enabled => {
APP.store.dispatch(setRequestingSubtitles(enabled));
'set-subtitles': (enabled, displaySubtitles, language) => {
APP.store.dispatch(setRequestingSubtitles(enabled, displaySubtitles, language));
},
'toggle-tile-view': () => {
sendAnalytics(createApiEvent('tile-view.toggled'));
@@ -835,8 +835,6 @@ function initCommands() {
return true;
}
logger.warn(`Unknown API command received: ${name}`);
return false;
});
transport.on('request', (request, callback) => {

10
package-lock.json generated
View File

@@ -59,7 +59,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/v1734.0.0+34ceebd2/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1736.0.0+8bee4514/lib-jitsi-meet.tgz",
"lodash": "4.17.21",
"moment": "2.29.4",
"moment-duration-format": "2.2.2",
@@ -11860,8 +11860,8 @@
},
"node_modules/lib-jitsi-meet": {
"version": "0.0.0",
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1734.0.0+34ceebd2/lib-jitsi-meet.tgz",
"integrity": "sha512-mHWUJ8Q4uhFsx2EZoRhgq8iGgitXig9hZ+uOuHana2YWjj1ZU0GhS5fl7VGr+LxtsonclQBBbGDt8/JkNLcfgg==",
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1736.0.0+8bee4514/lib-jitsi-meet.tgz",
"integrity": "sha512-GgbRX3fZKjhYpmC9PBx1iXML9S8oWXGkUAzVh3gbWhwPPz9dGFScEzUt90N7v+Z3JMQELHn1XoUbzHtPe08yHA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -27399,8 +27399,8 @@
}
},
"lib-jitsi-meet": {
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1734.0.0+34ceebd2/lib-jitsi-meet.tgz",
"integrity": "sha512-mHWUJ8Q4uhFsx2EZoRhgq8iGgitXig9hZ+uOuHana2YWjj1ZU0GhS5fl7VGr+LxtsonclQBBbGDt8/JkNLcfgg==",
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1736.0.0+8bee4514/lib-jitsi-meet.tgz",
"integrity": "sha512-GgbRX3fZKjhYpmC9PBx1iXML9S8oWXGkUAzVh3gbWhwPPz9dGFScEzUt90N7v+Z3JMQELHn1XoUbzHtPe08yHA==",
"requires": {
"@jitsi/js-utils": "2.2.1",
"@jitsi/logger": "2.0.2",

View File

@@ -65,7 +65,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/v1734.0.0+34ceebd2/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1736.0.0+8bee4514/lib-jitsi-meet.tgz",
"lodash": "4.17.21",
"moment": "2.29.4",
"moment-duration-format": "2.2.2",

View File

@@ -13,9 +13,12 @@ import React, {
} from 'react';
import { View, ViewStyle } from 'react-native';
import type { IRoomsInfo } from '../react/features/breakout-rooms/types';
import { appNavigate } from './react/features/app/actions.native';
import { App } from './react/features/app/components/App.native';
import { setAudioMuted, setVideoMuted } from './react/features/base/media/actions';
import { getRoomsInfo } from './react/features/breakout-rooms/functions';
interface IEventListeners {
@@ -28,6 +31,7 @@ interface IEventListeners {
onConferenceWillJoin?: Function;
onEnterPictureInPicture?: Function;
onParticipantJoined?: Function;
onParticipantLeft?: ({ id }: { id: string }) => void;
onReadyToClose?: Function;
}
@@ -48,10 +52,17 @@ interface IAppProps {
userInfo?: IUserInfo;
}
export interface JitsiRefProps {
close: Function;
setAudioMuted?: (muted: boolean) => void;
setVideoMuted?: (muted: boolean) => void;
getRoomsInfo?: () => IRoomsInfo;
}
/**
* Main React Native SDK component that displays a Jitsi Meet conference and gets all required params as props
*/
export const JitsiMeeting = forwardRef((props: IAppProps, ref) => {
export const JitsiMeeting = forwardRef<JitsiRefProps, IAppProps>((props, ref) => {
const [ appProps, setAppProps ] = useState({});
const app = useRef(null);
const {
@@ -81,6 +92,11 @@ export const JitsiMeeting = forwardRef((props: IAppProps, ref) => {
const dispatch = app.current.state.store.dispatch;
dispatch(setVideoMuted(muted));
},
getRoomsInfo: () => {
const state = app.current.state.store.getState();
return getRoomsInfo(state);
}
}));
@@ -118,6 +134,7 @@ export const JitsiMeeting = forwardRef((props: IAppProps, ref) => {
onConferenceLeft: eventListeners?.onConferenceLeft,
onEnterPictureInPicture: eventListeners?.onEnterPictureInPicture,
onParticipantJoined: eventListeners?.onParticipantJoined,
onParticipantLeft: eventListeners?.onParticipantLeft,
onReadyToClose: eventListeners?.onReadyToClose
},
'url': urlProps,

View File

@@ -40,14 +40,6 @@ export const LANGUAGES: Array<string> = Object.keys(LANGUAGES_RESOURCES);
*/
export const TRANSLATION_LANGUAGES: Array<string> = Object.keys(TRANSLATION_LANGUAGES_RESOURCES);
/**
* The available/supported translation languages head. (Languages displayed on the top ).
*
* @public
* @type {Array<string>}
*/
export const TRANSLATION_LANGUAGES_HEAD: Array<string> = [ 'en' ];
/**
* The default language.
*
@@ -58,6 +50,14 @@ export const TRANSLATION_LANGUAGES_HEAD: Array<string> = [ 'en' ];
*/
export const DEFAULT_LANGUAGE = 'en';
/**
* The available/supported translation languages head. (Languages displayed on the top ).
*
* @public
* @type {Array<string>}
*/
export const TRANSLATION_LANGUAGES_HEAD: Array<string> = [ DEFAULT_LANGUAGE ];
/**
* The options to initialize i18next with.
*

View File

@@ -1,8 +1,8 @@
// @ts-expect-error
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import { IStore } from '../app/types';
import { MEDIA_TYPE } from '../base/media/constants';
import { getTrackByMediaTypeAndParticipant } from '../base/tracks/functions.web';
import { getParticipantById } from '../base/participants/functions';
import { getVideoTrackByParticipant } from '../base/tracks/functions.web';
import { SET_SEE_WHAT_IS_BEING_SHARED } from './actionTypes';
@@ -19,11 +19,12 @@ export function captureLargeVideoScreenshot() {
const largeVideo = state['features/large-video'];
const promise = Promise.resolve();
if (!largeVideo) {
if (!largeVideo?.participantId) {
return promise;
}
const tracks = state['features/base/tracks'];
const participantTrack = getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, largeVideo.participantId);
const participant = getParticipantById(state, largeVideo.participantId);
const participantTrack = getVideoTrackByParticipant(state, participant);
// Participants that join the call video muted do not have a jitsiTrack attached.
if (!participantTrack?.jitsiTrack) {

View File

@@ -207,7 +207,7 @@ function _handleLobbyNotification(store: IStore) {
descriptionKey = 'notify.participantWantsToJoin';
notificationTitle = firstParticipant.name;
icon = NOTIFICATION_ICON.PARTICIPANT;
customActionNameKey = [ 'lobby.admit', 'lobby.reject' ];
customActionNameKey = [ 'participantsPane.actions.admit', 'participantsPane.actions.reject' ];
customActionType = [ BUTTON_TYPES.PRIMARY, BUTTON_TYPES.DESTRUCTIVE ];
customActionHandler = [ () => batch(() => {
dispatch(hideNotification(LOBBY_NOTIFICATION_ID));

View File

@@ -154,10 +154,11 @@ function _conferenceFailed({ getState }: IStore, next: Function, action: AnyActi
// prevented the user from joining a specific conference but the app may be
// able to eventually join the conference.
if (!action.error.recoverable) {
const { callUUID } = action.conference;
if (action?.conference?.callUUID) {
if (callUUID) {
delete action.conference.callUUID;
CallIntegration.reportCallFailed(action.conference.callUUIDID);
CallIntegration.reportCallFailed(callUUID);
}
}
@@ -184,9 +185,9 @@ function _conferenceJoined({ getState }: IStore, next: Function, action: AnyActi
return result;
}
if (action?.conference?.callUUID) {
const { callUUID } = action.conference;
const { callUUID } = action.conference;
if (callUUID) {
CallIntegration.reportConnectedOutgoingCall(callUUID)
.then(() => {
// iOS 13 doesn't like the mute state to be false before the call is started
@@ -229,9 +230,11 @@ function _conferenceLeft({ getState }: IStore, next: Function, action: AnyAction
return result;
}
if (action?.conference?.callUUID) {
const { callUUID } = action.conference;
if (callUUID) {
delete action.conference.callUUID;
CallIntegration.endCall(action.conference.callUUID);
CallIntegration.endCall(callUUID);
}
return result;
@@ -270,7 +273,7 @@ function _conferenceWillJoin({ dispatch, getState }: IStore, next: Function, act
}
// When assigning the call UUID, do so in upper case, since iOS will return
// it upper cased.
// it upper-cased.
conference.callUUID = (callUUID || uuidv4()).toUpperCase();
CallIntegration.startCall(conference.callUUID, handle, hasVideo)

View File

@@ -379,9 +379,10 @@ function _registerForNativeEvents(store: IStore) {
dispatch(sendMessage(message));
});
eventEmitter.addListener(ExternalAPI.SET_CLOSED_CAPTIONS_ENABLED, ({ enabled }: any) => {
dispatch(setRequestingSubtitles(enabled));
});
eventEmitter.addListener(ExternalAPI.SET_CLOSED_CAPTIONS_ENABLED,
({ enabled, displaySubtitles, language }: any) => {
dispatch(setRequestingSubtitles(enabled, displaySubtitles, language));
});
eventEmitter.addListener(ExternalAPI.TOGGLE_CAMERA, () => {
dispatch(toggleCameraFacingMode());

View File

@@ -9,7 +9,7 @@ import {
CONFERENCE_WILL_JOIN
} from '../../base/conference/actionTypes';
import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from '../../base/media/actionTypes';
import { PARTICIPANT_JOINED } from '../../base/participants/actionTypes';
import { PARTICIPANT_JOINED, PARTICIPANT_LEFT } from '../../base/participants/actionTypes';
import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry';
import StateListenerRegistry from '../../base/redux/StateListenerRegistry';
import { READY_TO_CLOSE } from '../external-api/actionTypes';
@@ -63,6 +63,14 @@ const { JMOngoingConference } = NativeModules;
rnSdkHandlers?.onParticipantJoined && rnSdkHandlers?.onParticipantJoined(participantInfo);
break;
}
case PARTICIPANT_LEFT: {
const { participant } = action;
const { id } = participant ?? {};
rnSdkHandlers?.onParticipantLeft && rnSdkHandlers?.onParticipantLeft({ id });
break;
}
case READY_TO_CLOSE:
rnSdkHandlers?.onReadyToClose && rnSdkHandlers?.onReadyToClose();
break;

View File

@@ -58,7 +58,7 @@ const ContextMenuLobbyParticipantReject = ({ participant: p }: IProps) => {
<Icon
size = { 24 }
src = { IconCloseLarge } />
<Text style = { styles.contextMenuItemText }>{ t('lobby.reject') }</Text>
<Text style = { styles.contextMenuItemText }>{ t('participantsPane.actions.reject') }</Text>
</TouchableOpacity>
</BottomSheet>
);

View File

@@ -19,8 +19,8 @@ interface IProps {
export const LobbyParticipantItem = ({ participant: p }: IProps) => {
const dispatch = useDispatch();
const admit = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, true)), [ dispatch ]);
const reject = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, false)), [ dispatch ]);
const admit = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, true)), [ dispatch, p.id ]);
const reject = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, false)), [ dispatch, p.id ]);
return (
<ParticipantItem
@@ -29,16 +29,16 @@ export const LobbyParticipantItem = ({ participant: p }: IProps) => {
key = { p.id }
participantID = { p.id } >
<Button
accessibilityLabel = 'lobby.reject'
labelKey = 'lobby.reject'
accessibilityLabel = 'participantsPane.actions.reject'
labelKey = 'participantsPane.actions.reject'
onClick = { reject }
style = { styles.lobbyButtonReject }
style = { styles.buttonReject }
type = { BUTTON_TYPES.DESTRUCTIVE } />
<Button
accessibilityLabel = 'lobby.admit'
labelKey = 'lobby.admit'
accessibilityLabel = 'participantsPane.actions.admit'
labelKey = 'participantsPane.actions.admit'
onClick = { admit }
style = { styles.lobbyButtonAdmit }
style = { styles.buttonAdmit }
type = { BUTTON_TYPES.PRIMARY } />
</ParticipantItem>
);

View File

@@ -28,15 +28,15 @@ const LobbyParticipantList = () => {
return (
<>
<View style = { styles.lobbyListDetails as ViewStyle } >
<View style = { styles.listDetails as ViewStyle } >
<Text style = { styles.lobbyListDescription as TextStyle }>
{ title }
</Text>
{
participants.length > 1 && (
<Button
accessibilityLabel = 'lobby.admitAll'
labelKey = 'lobby.admitAll'
accessibilityLabel = 'participantsPane.actions.admitAll'
labelKey = 'participantsPane.actions.admitAll'
mode = { BUTTON_MODES.TEXT }
onClick = { admitAll }
type = { BUTTON_TYPES.PRIMARY } />

View File

@@ -69,19 +69,9 @@ const MeetingParticipantList = () => {
: t('participantsPane.headings.participantsList',
{ count: participantsCount });
const { color, shareDialogVisible } = inviteOthersControl;
const visitorsCount = useSelector((state: IReduxState) => state['features/visitors'].count || 0);
const visitorsLabelText = visitorsCount > 0
? t('participantsPane.headings.visitors', { count: visitorsCount })
: undefined;
return (
<View style = { styles.meetingListContainer }>
{
visitorsCount > 0
&& <Text style = { styles.visitorsLabel }>
{ visitorsLabelText }
</Text>
}
<Text
style = { styles.meetingListDescription as TextStyle }>
{ title }

View File

@@ -8,6 +8,7 @@ import { isLocalParticipantModerator } from '../../../base/participants/function
import LobbyParticipantList from './LobbyParticipantList';
import MeetingParticipantList from './MeetingParticipantList';
import ParticipantsPaneFooter from './ParticipantsPaneFooter';
import VisitorsList from './VisitorsList';
import styles from './styles';
@@ -32,6 +33,7 @@ const ParticipantsPane = () => {
// eslint-disable-next-line react/jsx-no-bind
ListHeaderComponent = { () => (
<>
<VisitorsList />
<LobbyParticipantList />
<MeetingParticipantList />
</>

View File

@@ -0,0 +1,46 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { approveRequest, denyRequest } from '../../../visitors/actions';
import { IPromotionRequest } from '../../../visitors/types';
import ParticipantItem from './ParticipantItem';
import styles from './styles';
interface IProps {
/**
* Promotion request reference.
*/
request: IPromotionRequest;
}
export const VisitorsItem = ({ request: r }: IProps) => {
const dispatch = useDispatch();
const admit = useCallback(() => dispatch(approveRequest(r)), [ dispatch, r ]);
const reject = useCallback(() => dispatch(denyRequest(r)), [ dispatch, r ]);
const { from, nick } = r;
return (
<ParticipantItem
displayName = { nick ?? '' }
isKnockingParticipant = { true }
key = { from }
participantID = { from } >
<Button
accessibilityLabel = 'participantsPane.actions.reject'
labelKey = 'participantsPane.actions.reject'
onClick = { reject }
style = { styles.buttonReject }
type = { BUTTON_TYPES.DESTRUCTIVE } />
<Button
accessibilityLabel = 'participantsPane.actions.admit'
labelKey = 'participantsPane.actions.admit'
onClick = { admit }
style = { styles.buttonAdmit }
type = { BUTTON_TYPES.PRIMARY } />
</ParticipantItem>
);
};

View File

@@ -0,0 +1,65 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_MODES, BUTTON_TYPES } from '../../../base/ui/constants.native';
import { admitMultiple } from '../../../visitors/actions';
import { getPromotionRequests } from '../../../visitors/functions';
import { VisitorsItem } from './VisitorsItem';
import styles from './styles';
const VisitorsList = () => {
const visitorsCount = useSelector((state: IReduxState) => state['features/visitors'].count || 0);
const dispatch = useDispatch();
const requests = useSelector(getPromotionRequests);
const admitAll = useCallback(() => {
dispatch(admitMultiple(requests));
}, [ dispatch, requests ]);
const { t } = useTranslation();
if (visitorsCount <= 0) {
return null;
}
let title = t('participantsPane.headings.visitors', { count: visitorsCount });
if (requests.length > 0) {
title += t('participantsPane.headings.visitorRequests', { count: requests.length });
}
return (
<>
<View style = { styles.listDetails as ViewStyle } >
<Text style = { styles.visitorsLabel }>
{ title }
</Text>
{
requests.length > 1 && (
<Button
accessibilityLabel = 'participantsPane.actions.admitAll'
labelKey = 'participantsPane.actions.admitAll'
mode = { BUTTON_MODES.TEXT }
onClick = { admitAll }
type = { BUTTON_TYPES.PRIMARY } />
)
}
</View>
{
requests.map(r => (
<VisitorsItem
key = { r.from }
request = { r } />)
)
}
</>
);
};
export default VisitorsList;

View File

@@ -164,12 +164,12 @@ export default {
color: BaseTheme.palette.uiBackground
},
lobbyButtonAdmit: {
buttonAdmit: {
position: 'absolute',
right: 16
},
lobbyButtonReject: {
buttonReject: {
position: 'absolute',
right: 112
},
@@ -178,7 +178,7 @@ export default {
...participantListDescription
},
lobbyListDetails: {
listDetails: {
alignItems: 'center',
display: 'flex',
flexDirection: 'row',

View File

@@ -72,9 +72,9 @@ export const LobbyParticipantItem = ({
const renderAdmitButton = () => (
<Button
accessibilityLabel = { `${t('lobby.admit')} ${p.name}` }
accessibilityLabel = { `${t('participantsPane.actions.admit')} ${p.name}` }
className = { styles.button }
labelKey = { 'lobby.admit' }
labelKey = { 'participantsPane.actions.admit' }
onClick = { admit }
size = 'small'
testId = { `admit-${id}` } />);
@@ -116,18 +116,18 @@ export const LobbyParticipantItem = ({
} ] } />
<ContextMenuItemGroup
actions = { [ {
accessibilityLabel: `${t('lobby.reject')} ${p.name}`,
accessibilityLabel: `${t('participantsPane.actions.reject')} ${p.name}`,
onClick: reject,
testId: `reject-${id}`,
icon: IconUserDeleted,
text: t('lobby.reject')
text: t('participantsPane.actions.reject')
} ] } />
</ContextMenu>
</> : <>
<Button
accessibilityLabel = { `${t('lobby.reject')} ${p.name}` }
accessibilityLabel = { `${t('participantsPane.actions.reject')} ${p.name}` }
className = { styles.button }
labelKey = { 'lobby.reject' }
labelKey = { 'participantsPane.actions.reject' }
onClick = { reject }
size = 'small'
testId = { `reject-${id}` }

View File

@@ -91,7 +91,7 @@ export default function LobbyParticipants() {
participants.length > 1
&& <div
className = { classes.link }
onClick = { admitAll }>{t('lobby.admitAll')}</div>
onClick = { admitAll }>{t('participantsPane.actions.admitAll')}</div>
}
</div>
<LobbyParticipantItems
@@ -117,7 +117,7 @@ export default function LobbyParticipants() {
className = { classes.icon }
size = { 20 }
src = { IconCheck } />
<span>{ t('lobby.admit') }</span>
<span>{ t('participantsPane.actions.admit') }</span>
</li>
<li
className = { classes.drawerItem }
@@ -126,7 +126,7 @@ export default function LobbyParticipants() {
className = { classes.icon }
size = { 20 }
src = { IconCloseLarge } />
<span>{ t('lobby.reject')}</span>
<span>{ t('participantsPane.actions.reject')}</span>
</li>
</ul>
</Drawer>

View File

@@ -105,10 +105,9 @@ function MeetingParticipants({
const participantActionEllipsisLabel = t('participantsPane.actions.moreParticipantOptions');
const youText = t('chat.you');
const isBreakoutRoom = useSelector(isInBreakoutRoom);
const visitorsCount = useSelector((state: IReduxState) => state['features/visitors'].count || 0);
const _isCurrentRoomRenamable = useSelector(isCurrentRoomRenamable);
const { classes: styles, cx } = useStyles();
const { classes: styles } = useStyles();
return (
<>
@@ -118,11 +117,6 @@ function MeetingParticipants({
role = 'heading'>
{ t('participantsPane.title') }
</span>
{visitorsCount > 0 && (
<div className = { cx(styles.heading, styles.headingW) }>
{t('participantsPane.headings.visitors', { count: visitorsCount })}
</div>
)}
<div className = { styles.heading }>
{currentRoom?.name
? `${currentRoom.name} (${participantsCount})`

View File

@@ -26,7 +26,7 @@ import { RoomList } from '../breakout-rooms/components/web/RoomList';
import { FooterContextMenu } from './FooterContextMenu';
import LobbyParticipants from './LobbyParticipants';
import MeetingParticipants from './MeetingParticipants';
import VisitorsList from './VisitorsList';
const useStyles = makeStyles()(theme => {
return {
@@ -171,6 +171,8 @@ const ParticipantsPane = () => {
onClick = { onClosePane } />
</div>
<div className = { classes.container }>
<VisitorsList />
<br className = { classes.antiCollapse } />
<LobbyParticipants />
<br className = { classes.antiCollapse } />
<MeetingParticipants

View File

@@ -0,0 +1,80 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import { approveRequest, denyRequest } from '../../../visitors/actions';
import { IPromotionRequest } from '../../../visitors/types';
import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants';
import ParticipantItem from './ParticipantItem';
interface IProps {
/**
* Promotion request reference.
*/
request: IPromotionRequest;
}
const useStyles = makeStyles()(theme => {
return {
button: {
marginRight: theme.spacing(2)
},
moreButton: {
paddingRight: '6px',
paddingLeft: '6px',
marginRight: theme.spacing(2)
},
contextMenu: {
position: 'fixed',
top: 'auto',
marginRight: '8px'
}
};
});
export const VisitorsItem = ({
request: r
}: IProps) => {
const { from, nick } = r;
const { t } = useTranslation();
const { classes: styles } = useStyles();
const dispatch = useDispatch();
const admit = useCallback(() => dispatch(approveRequest(r)), [ dispatch, r ]);
const reject = useCallback(() => dispatch(denyRequest(r)), [ dispatch, r ]);
return (
<ParticipantItem
actionsTrigger = { ACTION_TRIGGER.PERMANENT }
audioMediaState = { MEDIA_STATE.NONE }
displayName = { nick }
participantID = { from }
raisedHand = { true }
videoMediaState = { MEDIA_STATE.NONE }
youText = { t('chat.you') }>
{<>
<Button
accessibilityLabel = { `${t('participantsPane.actions.reject')} ${r.nick}` }
className = { styles.button }
labelKey = 'participantsPane.actions.reject'
onClick = { reject }
size = 'small'
testId = { `reject-${from}` }
type = { BUTTON_TYPES.DESTRUCTIVE } />
<Button
accessibilityLabel = { `${t('participantsPane.actions.admit')} ${r.nick}` }
className = { styles.button }
labelKey = 'participantsPane.actions.admit'
onClick = { admit }
size = 'small'
testId = { `admit-${from}` } />
</>
}
</ParticipantItem>
);
};

View File

@@ -0,0 +1,111 @@
import React, { 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 { admitMultiple } from '../../../visitors/actions';
import { getPromotionRequests } from '../../../visitors/functions';
import { VisitorsItem } from './VisitorsItem';
const useStyles = makeStyles()(theme => {
return {
container: {
margin: `${theme.spacing(3)} 0`
},
headingW: {
color: theme.palette.warning02
},
drawerActions: {
listStyleType: 'none',
margin: 0,
padding: 0
},
drawerItem: {
alignItems: 'center',
color: theme.palette.text01,
display: 'flex',
padding: '12px 16px',
...withPixelLineHeight(theme.typography.bodyShortRegularLarge),
'&:first-child': {
marginTop: '15px'
},
'&:hover': {
cursor: 'pointer',
background: theme.palette.action02
}
},
icon: {
marginRight: 16
},
headingContainer: {
alignItems: 'center',
display: 'flex',
justifyContent: 'space-between'
},
heading: {
...withPixelLineHeight(theme.typography.bodyShortBold),
color: theme.palette.text02
},
link: {
...withPixelLineHeight(theme.typography.labelBold),
color: theme.palette.link01,
cursor: 'pointer'
}
};
});
/**
* Component used to display a list of visitors waiting for approval to join the main meeting.
*
* @returns {ReactNode}
*/
export default function VisitorsList() {
const requests = useSelector(getPromotionRequests);
const visitorsCount = useSelector((state: IReduxState) => state['features/visitors'].count || 0);
const { t } = useTranslation();
const { classes, cx } = useStyles();
const dispatch = useDispatch();
const admitAll = useCallback(() => {
dispatch(admitMultiple(requests));
}, [ dispatch, requests ]);
if (visitorsCount <= 0) {
return null;
}
return (
<>
<div className = { classes.headingContainer }>
<div className = { cx(classes.heading, classes.headingW) }>
{ t('participantsPane.headings.visitors', { count: visitorsCount })}
{ requests.length > 0
&& t('participantsPane.headings.visitorRequests', { count: requests.length }) }
</div>
{
requests.length > 1
&& <div
className = { classes.link }
onClick = { admitAll }>{t('participantsPane.actions.admitAll')}</div>
}
</div>
<div
className = { classes.container }
id = 'visitor-list'>
{
requests.map(r => (
<VisitorsItem
key = { r.from }
request = { r } />)
)
}
</div>
</>
);
}

View File

@@ -24,16 +24,16 @@ export function useLobbyActions(participant?: IDrawerParticipant | null, closeDr
e.stopPropagation();
dispatch(approveKnockingParticipant(participant?.participantID ?? ''));
closeDrawer?.();
}, [ dispatch, closeDrawer ]),
}, [ dispatch, closeDrawer, participant?.participantID ]),
useCallback(() => {
dispatch(rejectKnockingParticipant(participant?.participantID ?? ''));
closeDrawer?.();
}, [ dispatch, closeDrawer ]),
}, [ dispatch, closeDrawer, participant?.participantID ]),
useCallback(() => {
dispatch(handleLobbyChatInitialized(participant?.participantID ?? ''));
}, [ dispatch ])
}, [ dispatch, participant?.participantID ])
];
}

View File

@@ -63,13 +63,23 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
conference.on(JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
(user: any, data: any) => {
data.type === COMMAND_NEW_POLL ? data.senderId = user._id : data.voterId = user._id;
_handleReceivePollsMessage(data, dispatch);
const isNewPoll = data.type === COMMAND_NEW_POLL;
_handleReceivePollsMessage({
...data,
senderId: isNewPoll ? user._id : undefined,
voterId: isNewPoll ? undefined : user._id
}, dispatch);
});
conference.on(JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED,
(id: any, data: any) => {
data.type === COMMAND_NEW_POLL ? data.senderId = id : data.voterId = id;
_handleReceivePollsMessage(data, dispatch);
const isNewPoll = data.type === COMMAND_NEW_POLL;
_handleReceivePollsMessage({
...data,
senderId: isNewPoll ? id : undefined,
voterId: isNewPoll ? undefined : id
}, dispatch);
});
break;

View File

@@ -17,6 +17,11 @@ interface IProps {
*/
_password?: string;
/**
* Number of digits used in the room-lock password.
*/
_passwordNumberOfDigits?: number;
/**
* The {@code JitsiConference} which requires a password.
*
@@ -88,6 +93,15 @@ class PasswordRequiredPrompt extends Component<IProps, IState> {
*/
render() {
const { password } = this.state;
const { _passwordNumberOfDigits } = this.props;
const textInputProps: any = {
secureTextEntry: true
};
if (_passwordNumberOfDigits) {
textInputProps.keyboardType = 'numeric';
textInputProps.maxLength = _passwordNumberOfDigits;
}
return (
<InputDialog
@@ -96,9 +110,7 @@ class PasswordRequiredPrompt extends Component<IProps, IState> {
messageKey = { password ? 'dialog.incorrectRoomLockPassword' : undefined }
onCancel = { this._onCancel }
onSubmit = { this._onSubmit }
textInputProps = {{
secureTextEntry: true
}}
textInputProps = { textInputProps }
titleKey = 'dialog.password' />
);
}
@@ -142,8 +154,11 @@ class PasswordRequiredPrompt extends Component<IProps, IState> {
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { roomPasswordNumberOfDigits } = state['features/base/config'];
return {
_password: state['features/base/conference'].password
_password: state['features/base/conference'].password,
_passwordNumberOfDigits: roomPasswordNumberOfDigits
};
}

View File

@@ -34,18 +34,6 @@ export const REMOVE_TRANSCRIPT_MESSAGE = 'REMOVE_TRANSCRIPT_MESSAGE';
*/
export const UPDATE_TRANSCRIPT_MESSAGE = 'UPDATE_TRANSCRIPT_MESSAGE';
/**
* The type of (redux) action which indicates that a transcript with an
* given message_id to be added or updated is received.
*
* {
* type: UPDATE_TRANSLATION_LANGUAGE,
* transcriptMessageID: string,
* newTranscriptMessage: Object
* }
*/
export const UPDATE_TRANSLATION_LANGUAGE = 'UPDATE_TRANSLATION_LANGUAGE';
/**
* The type of (redux) action which indicates that the user pressed the
* ClosedCaption button, to either enable or disable subtitles based on the

View File

@@ -1,10 +1,11 @@
import { DEFAULT_LANGUAGE } from '../base/i18n/i18next';
import {
ENDPOINT_MESSAGE_RECEIVED,
REMOVE_TRANSCRIPT_MESSAGE,
SET_REQUESTING_SUBTITLES,
TOGGLE_REQUESTING_SUBTITLES,
UPDATE_TRANSCRIPT_MESSAGE,
UPDATE_TRANSLATION_LANGUAGE
UPDATE_TRANSCRIPT_MESSAGE
} from './actionTypes';
/**
@@ -80,29 +81,23 @@ export function toggleRequestingSubtitles() {
* Signals that the local user has enabled or disabled the subtitles.
*
* @param {boolean} enabled - The new state of the subtitles.
* @param {boolean} displaySubtitles - Whether to display subtitles or not.
* @param {string} language - The language of the subtitles.
* @returns {{
* type: SET_REQUESTING_SUBTITLES,
* enabled: boolean
* enabled: boolean,
* displaySubtitles: boolean,
* language: string
* }}
*/
export function setRequestingSubtitles(enabled: boolean) {
export function setRequestingSubtitles(
enabled: boolean,
displaySubtitles = true,
language: string | null = `translation-languages:${DEFAULT_LANGUAGE}`) {
return {
type: SET_REQUESTING_SUBTITLES,
enabled
};
}
/**
* Signals that the local user has selected language for the translation.
*
* @param {string} value - The selected language for translation.
* @returns {{
* type: UPDATE_TRANSLATION_LANGUAGE
* }}
*/
export function updateTranslationLanguage(value: string) {
return {
type: UPDATE_TRANSLATION_LANGUAGE,
value
displaySubtitles,
enabled,
language
};
}

View File

@@ -8,9 +8,7 @@ export * from './actions.any';
/**
* Signals that the local user has toggled the LanguageSelector button.
*
* @returns {{
* type: UPDATE_TRANSLATION_LANGUAGE
* }}
* @returns {Function}
*/
export function toggleLanguageSelectorDialog() {
return function(dispatch: IStore['dispatch']) {

View File

@@ -9,7 +9,12 @@ import { IReduxState } from '../../app/types';
export interface IAbstractCaptionsProps {
/**
* Whether local participant is requesting to see subtitles.
* Whether local participant is displaying subtitles.
*/
_displaySubtitles: boolean;
/**
* Whether local participant is requesting subtitles.
*/
_requestingSubtitles: boolean;
@@ -34,9 +39,9 @@ export class AbstractCaptions<P extends IAbstractCaptionsProps> extends Componen
* @returns {ReactElement}
*/
render(): any {
const { _requestingSubtitles, _transcripts } = this.props;
const { _displaySubtitles, _requestingSubtitles, _transcripts } = this.props;
if (!_requestingSubtitles || !_transcripts || !_transcripts.size) {
if (!_requestingSubtitles || !_displaySubtitles || !_transcripts || !_transcripts.size) {
return null;
}
@@ -95,7 +100,7 @@ function _constructTranscripts(state: IReduxState): Map<string, string> {
for (const [ id, transcriptMessage ] of _transcriptMessages) {
if (transcriptMessage) {
let text = `${transcriptMessage.participantName}: `;
let text = `${transcriptMessage.participant.name}: `;
if (transcriptMessage.final) {
text += transcriptMessage.final;
@@ -125,10 +130,11 @@ function _constructTranscripts(state: IReduxState): Map<string, string> {
* }}
*/
export function _abstractMapStateToProps(state: IReduxState) {
const { _requestingSubtitles } = state['features/subtitles'];
const { _displaySubtitles, _requestingSubtitles } = state['features/subtitles'];
const transcripts = _constructTranscripts(state);
return {
_displaySubtitles,
_requestingSubtitles,
// avoid re-renders by setting to prop new empty Map instances.

View File

@@ -8,7 +8,7 @@ import { maybeShowPremiumFeatureDialog } from '../../jaas/actions';
export interface IAbstractProps extends AbstractButtonProps {
_language: string;
_language: string | null;
/**
* Whether the local participant is currently requesting subtitles.

View File

@@ -1,4 +1,4 @@
import React, { ComponentType, useCallback, useEffect, useState } from 'react';
import React, { ComponentType, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
@@ -7,12 +7,12 @@ import {
TRANSLATION_LANGUAGES,
TRANSLATION_LANGUAGES_HEAD
} from '../../base/i18n/i18next';
import { setRequestingSubtitles, updateTranslationLanguage } from '../actions.any';
import { setRequestingSubtitles } from '../actions.any';
export interface IAbstractLanguageSelectorDialogProps {
dispatch: IStore['dispatch'];
language: string;
language: string | null;
listItems: Array<any>;
onLanguageSelected: (e: string) => void;
subtitles: string;
@@ -30,10 +30,10 @@ export interface IAbstractLanguageSelectorDialogProps {
const AbstractLanguageSelectorDialog = (Component: ComponentType<IAbstractLanguageSelectorDialogProps>) => () => {
const dispatch = useDispatch();
const { t } = useTranslation();
const off = 'transcribing.subtitlesOff';
const noLanguageLabel = 'transcribing.subtitlesOff';
const [ subtitles, setSubtiles ] = useState(off);
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;
@@ -42,7 +42,7 @@ const AbstractLanguageSelectorDialog = (Component: ComponentType<IAbstractLangua
// 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 = [ off, ...languagesHead ];
const fixedItems = [ noLanguageLabel, ...languagesHead ];
const translationLanguages = transcription?.translationLanguages ?? TRANSLATION_LANGUAGES;
const languages = translationLanguages
.map((lang: string) => `translation-languages:${lang}`)
@@ -58,14 +58,12 @@ const AbstractLanguageSelectorDialog = (Component: ComponentType<IAbstractLangua
};
});
useEffect(() => {
language ? setSubtiles(language) : setSubtiles(off);
}, []);
const onLanguageSelected = useCallback((value: string) => {
const selectedLanguage = value === noLanguageLabel ? null : value;
const enabled = Boolean(selectedLanguage);
const displaySubtitles = enabled;
const onLanguageSelected = useCallback((e: string) => {
setSubtiles(e);
dispatch(updateTranslationLanguage(e));
dispatch(setRequestingSubtitles(e !== off));
dispatch(setRequestingSubtitles(enabled, displaySubtitles, selectedLanguage));
}, [ language ]);
return (

View File

@@ -22,7 +22,7 @@ class ClosedCaptionButton
icon = IconSubtitles;
label = 'toolbar.startSubtitles';
labelProps = {
language: this.props.t(this.props._language),
language: this.props.t(this.props._language ?? 'transcribing.subtitlesOff'),
languages: this.props.t(this.props.languages ?? ''),
languagesHead: this.props.t(this.props.languagesHead ?? '')
};

View File

@@ -18,7 +18,7 @@ class ClosedCaptionButton
tooltip = 'transcribing.ccButtonTooltip';
label = 'toolbar.startSubtitles';
labelProps = {
language: this.props.t(this.props._language),
language: this.props.t(this.props._language ?? 'transcribing.subtitlesOff'),
languages: this.props.t(this.props.languages ?? ''),
languagesHead: this.props.t(this.props.languagesHead ?? '')
};

View File

@@ -55,12 +55,15 @@ MiddlewareRegistry.register(store => next => action => {
case ENDPOINT_MESSAGE_RECEIVED:
return _endpointMessageReceived(store, next, action);
case TOGGLE_REQUESTING_SUBTITLES:
_requestingSubtitlesChange(store);
case TOGGLE_REQUESTING_SUBTITLES: {
const state = store.getState()['features/subtitles'];
const toggledValue = !state._requestingSubtitles;
_requestingSubtitlesChange(store, toggledValue, state._language);
break;
}
case SET_REQUESTING_SUBTITLES:
_requestingSubtitlesChange(store);
_requestingSubtitlesSet(store, action.enabled);
_requestingSubtitlesChange(store, action.enabled, action.language);
break;
}
@@ -91,23 +94,28 @@ function _endpointMessageReceived({ dispatch, getState }: IStore, next: Function
}
const state = getState();
const translationLanguage
const language
= state['features/base/conference'].conference
?.getLocalParticipantProperty(P_NAME_TRANSLATION_LANGUAGE);
try {
const transcriptMessageID = json.message_id;
const participantName = json.participant.name;
const { name, id, avatar_url: avatarUrl } = json.participant;
const participant = {
avatarUrl,
id,
name
};
if (json.type === JSON_TYPE_TRANSLATION_RESULT
&& json.language === translationLanguage) {
&& json.language === language) {
// Displays final results in the target language if translation is
// enabled.
const newTranscriptMessage = {
clearTimeOut: undefined,
final: json.text,
participantName
participant
};
_setClearerOnTranscriptMessage(dispatch,
@@ -115,8 +123,7 @@ function _endpointMessageReceived({ dispatch, getState }: IStore, next: Function
dispatch(updateTranscriptMessage(transcriptMessageID,
newTranscriptMessage));
} else if (json.type === JSON_TYPE_TRANSCRIPTION_RESULT
&& json.language.slice(0, 2) === translationLanguage) {
} else if (json.type === JSON_TYPE_TRANSCRIPTION_RESULT && json.language.slice(0, 2) === language) {
// Displays interim and final results without any translation if
// translations are disabled.
@@ -125,10 +132,11 @@ function _endpointMessageReceived({ dispatch, getState }: IStore, next: Function
// We update the previous transcript message with the same
// message ID or adds a new transcript message if it does not
// exist in the map.
const existingMessage = state['features/subtitles']._transcriptMessages.get(transcriptMessageID);
const newTranscriptMessage: any = {
...state['features/subtitles']._transcriptMessages
.get(transcriptMessageID)
|| { participantName }
clearTimeOut: existingMessage?.clearTimeOut,
language,
participant
};
_setClearerOnTranscriptMessage(dispatch,
@@ -144,7 +152,6 @@ function _endpointMessageReceived({ dispatch, getState }: IStore, next: Function
// stable field of the state and remove the previously
// unstable results
newTranscriptMessage.stable = text;
newTranscriptMessage.unstable = undefined;
} else {
// Otherwise, this result has an unstable result, which we
@@ -157,9 +164,13 @@ function _endpointMessageReceived({ dispatch, getState }: IStore, next: Function
// Notify the external API too.
if (typeof APP !== 'undefined') {
const sanitizedTranscriptMessage = { ...newTranscriptMessage };
delete sanitizedTranscriptMessage.clearTimeOut;
APP.API.notifyTranscriptionChunkReceived({
messageID: transcriptMessageID,
...newTranscriptMessage
...sanitizedTranscriptMessage
});
}
}
@@ -175,43 +186,27 @@ function _endpointMessageReceived({ dispatch, getState }: IStore, next: Function
* and Jigasi to decide whether the transcriber needs to be in the room.
*
* @param {Store} store - The redux store.
* @param {boolean} enabled - Whether subtitles should be enabled or not.
* @param {string} language - The language to use for translation.
* @private
* @returns {void}
*/
function _requestingSubtitlesChange({ getState }: IStore) {
const state = getState();
const { _language } = state['features/subtitles'];
const { conference } = state['features/base/conference'];
const requestingSubtitles = _language !== 'transcribing.subtitlesOff';
conference?.setLocalParticipantProperty(
P_NAME_REQUESTING_TRANSCRIPTION,
requestingSubtitles);
if (requestingSubtitles) {
conference?.setLocalParticipantProperty(
P_NAME_TRANSLATION_LANGUAGE,
_language.replace('translation-languages:', ''));
}
}
/**
* Set the local property 'requestingTranscription'. This will cause Jicofo
* and Jigasi to decide whether the transcriber needs to be in the room.
*
* @param {Store} store - The redux store.
* @param {boolean} enabled - The new state of the subtitles.
* @private
* @returns {void}
*/
function _requestingSubtitlesSet({ getState }: IStore, enabled: boolean) {
function _requestingSubtitlesChange(
{ getState }: IStore,
enabled: boolean,
language?: string | null) {
const state = getState();
const { conference } = state['features/base/conference'];
conference?.setLocalParticipantProperty(
P_NAME_REQUESTING_TRANSCRIPTION,
enabled);
if (enabled && language) {
conference?.setLocalParticipantProperty(
P_NAME_TRANSLATION_LANGUAGE,
language.replace('translation-languages:', ''));
}
}
/**

View File

@@ -2,16 +2,17 @@ import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
REMOVE_TRANSCRIPT_MESSAGE,
SET_REQUESTING_SUBTITLES, UPDATE_TRANSCRIPT_MESSAGE, UPDATE_TRANSLATION_LANGUAGE
SET_REQUESTING_SUBTITLES, TOGGLE_REQUESTING_SUBTITLES, UPDATE_TRANSCRIPT_MESSAGE
} from './actionTypes';
/**
* Default State for 'features/transcription' feature.
*/
const defaultState = {
_displaySubtitles: true,
_transcriptMessages: new Map(),
_requestingSubtitles: false,
_language: 'transcribing.subtitlesOff'
_language: null
};
interface ITranscriptMessage {
@@ -22,7 +23,8 @@ interface ITranscriptMessage {
}
export interface ISubtitlesState {
_language: string;
_displaySubtitles: boolean;
_language: string | null;
_requestingSubtitles: boolean;
_transcriptMessages: Map<string, ITranscriptMessage> | any;
}
@@ -38,16 +40,18 @@ ReducerRegistry.register<ISubtitlesState>('features/subtitles', (
return _removeTranscriptMessage(state, action);
case UPDATE_TRANSCRIPT_MESSAGE:
return _updateTranscriptMessage(state, action);
case UPDATE_TRANSLATION_LANGUAGE:
return {
...state,
_language: action.value
};
case SET_REQUESTING_SUBTITLES:
return {
...state,
_displaySubtitles: action.displaySubtitles,
_language: action.language,
_requestingSubtitles: action.enabled
};
case TOGGLE_REQUESTING_SUBTITLES:
return {
...state,
_requestingSubtitles: !state._requestingSubtitles
};
}
return state;

View File

@@ -92,8 +92,12 @@ StateListenerRegistry.register(
isTileView: isLayoutTileView(state)
};
},
/* listener */({ clientHeight, isTileView }, store) => {
/* listener */({ clientHeight, isTileView }, store, { isTileView: previousIsTileView }) => {
if (!isTileView) {
if (previousIsTileView) {
store.dispatch(setShiftUp(false));
}
return;
}
throttledCheckOverlap(clientHeight, store);

View File

@@ -17,3 +17,24 @@ export const UPDATE_VISITORS_COUNT = 'UPDATE_VISITORS_COUNT';
* }
*/
export const I_AM_VISITOR_MODE = 'I_AM_VISITOR_MODE';
/**
* The type of (redux) action which indicates that a promotion request was received from a visitor.
*
* {
* type: VISITOR_PROMOTION_REQUEST,
* nick: string,
* from: string
* }
*/
export const VISITOR_PROMOTION_REQUEST = 'VISITOR_PROMOTION_REQUEST';
/**
* The type of (redux) action which indicates that a promotion response denied was received.
*
* {
* type: CLEAR_VISITOR_PROMOTION_REQUEST,
* request: IPromotionRequest
* }
*/
export const CLEAR_VISITOR_PROMOTION_REQUEST = 'CLEAR_VISITOR_PROMOTION_REQUEST';

View File

@@ -1,4 +1,107 @@
import { I_AM_VISITOR_MODE, UPDATE_VISITORS_COUNT } from './actionTypes';
import { IStore } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import {
CLEAR_VISITOR_PROMOTION_REQUEST,
I_AM_VISITOR_MODE,
UPDATE_VISITORS_COUNT,
VISITOR_PROMOTION_REQUEST
} from './actionTypes';
import { IPromotionRequest } from './types';
/**
* Action used to admit multiple participants in the conference.
*
* @param {Array<Object>} requests - A list of visitors requests.
* @returns {Function}
*/
export function admitMultiple(requests: Array<IPromotionRequest>): Function {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
requests.forEach(r => {
conference?.sendMessage({
type: 'visitors',
action: 'promotion-response',
approved: true,
id: r.from
});
});
};
}
/**
* Approves the request of a visitor to join the main meeting.
*
* @param {IPromotionRequest} request - The request from the visitor.
* @returns {Function}
*/
export function approveRequest(request: IPromotionRequest): Function {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
conference?.sendMessage({
type: 'visitors',
action: 'promotion-response',
approved: true,
id: request.from
});
dispatch(clearPromotionRequest(request));
};
}
/**
* Denies the request of a visitor to join the main meeting.
*
* @param {IPromotionRequest} request - The request from the visitor.
* @returns {Function}
*/
export function denyRequest(request: IPromotionRequest): Function {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
conference?.sendMessage({
type: 'visitors',
action: 'promotion-response',
approved: false,
id: request.from
});
dispatch(clearPromotionRequest(request));
};
}
/**
* Removes a promotion request from the state.
*
* @param {IPromotionRequest} request - The request.
* @returns {{
* type: CLEAR_VISITOR_PROMOTION_REQUEST,
* request: IPromotionRequest
* }}
*/
export function clearPromotionRequest(request: IPromotionRequest) {
return {
type: CLEAR_VISITOR_PROMOTION_REQUEST,
request
};
}
/**
* Visitor has sent us a promotion request.
*
* @param {IPromotionRequest} request - The request.
* @returns {{
* type: VISITOR_PROMOTION_REQUEST,
* }}
*/
export function promotionRequestReceived(request: IPromotionRequest) {
return {
type: VISITOR_PROMOTION_REQUEST,
request
};
}
/**
* Sets Visitors mode on or off.

View File

@@ -1,3 +1,4 @@
import { IReduxState } from '../app/types';
import { IStateful } from '../base/app/types';
import { toState } from '../base/redux/functions';
@@ -13,6 +14,16 @@ export function getVisitorsShortText(visitorsCount: number) {
return visitorsCount > 100 ? `${Math.round(visitorsCount / 100) / 10} K` : String(visitorsCount);
}
/**
* Selector to return a list of promotion requests from visitors.
*
* @param {IReduxState} state - State object.
* @returns {Array<Object>}
*/
export function getPromotionRequests(state: IReduxState) {
return state['features/visitors'].promotionRequests;
}
/**
* Whether current UI is in visitor mode.
*

View File

@@ -1,10 +1,12 @@
import { CONFERENCE_JOINED, CONFERENCE_JOIN_IN_PROGRESS } from '../base/conference/actionTypes';
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
import { raiseHand } from '../base/participants/actions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { showNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { updateVisitorsCount } from './actions';
import { clearPromotionRequest, promotionRequestReceived, updateVisitorsCount } from './actions';
import { getPromotionRequests } from './functions';
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
switch (action.type) {
@@ -21,12 +23,43 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
break;
}
case CONFERENCE_JOINED: {
const { conference } = action;
if (getState()['features/visitors'].iAmVisitor) {
dispatch(showNotification({
titleKey: 'visitors.notification.title',
descriptionKey: 'visitors.notification.description'
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
}
conference.on(JitsiConferenceEvents.VISITORS_MESSAGE, (
msg: { from: string; nick: string; on: boolean; }) => {
const request = {
from: msg.from,
nick: msg.nick
};
if (msg.on) {
dispatch(promotionRequestReceived(request));
} else {
dispatch(clearPromotionRequest(request));
}
});
conference.on(JitsiConferenceEvents.VISITORS_REJECTION, () => {
dispatch(raiseHand(false));
});
conference.on(JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
(user: any, data: any) => {
if (data?.action === 'promotion-response' && data.approved) {
const request = getPromotionRequests(getState())
.find(r => r.from === data.id);
request && dispatch(clearPromotionRequest(request));
}
});
break;
}
}

View File

@@ -1,17 +1,25 @@
import { CONFERENCE_WILL_LEAVE } from '../base/conference/actionTypes';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import { I_AM_VISITOR_MODE, UPDATE_VISITORS_COUNT } from './actionTypes';
import {
CLEAR_VISITOR_PROMOTION_REQUEST,
I_AM_VISITOR_MODE,
UPDATE_VISITORS_COUNT,
VISITOR_PROMOTION_REQUEST
} from './actionTypes';
import { IPromotionRequest } from './types';
const DEFAULT_STATE = {
count: -1,
iAmVisitor: false,
showNotification: false
showNotification: false,
promotionRequests: []
};
export interface IVisitorsState {
count?: number;
iAmVisitor: boolean;
promotionRequests: IPromotionRequest[];
}
ReducerRegistry.register<IVisitorsState>('features/visitors', (state = DEFAULT_STATE, action): IVisitorsState => {
switch (action.type) {
@@ -42,6 +50,26 @@ ReducerRegistry.register<IVisitorsState>('features/visitors', (state = DEFAULT_S
iAmVisitor: action.enabled
};
}
case VISITOR_PROMOTION_REQUEST: {
const currentRequests = state.promotionRequests || [];
currentRequests.push(action.request);
return {
...state,
promotionRequests: [ ...currentRequests ]
};
}
case CLEAR_VISITOR_PROMOTION_REQUEST: {
let currentRequests = state.promotionRequests || [];
currentRequests = currentRequests.filter(r => r.from !== action.request.from);
return {
...state,
promotionRequests: currentRequests
};
}
}
return state;

View File

@@ -0,0 +1,4 @@
export interface IPromotionRequest {
from: string;
nick: string;
}

View File

@@ -0,0 +1,54 @@
module:set_global();
local traceback = require "util.debug".traceback;
local pposix = require "util.pposix";
local os_date = os.date;
local render_filename = require "util.interpolation".new("%b{}", function (s) return s; end, {
yyyymmdd = function (t)
return os_date("%Y%m%d", t);
end;
hhmmss = function (t)
return os_date("%H%M%S", t);
end;
});
local count = 0;
local function get_filename(filename_template)
filename_template = filename_template;
return render_filename(filename_template, {
paths = prosody.paths;
pid = pposix.getpid();
count = count;
time = os.time();
});
end
local default_filename_template = "{paths.data}/traceback-{pid}-{count}.log";
local filename_template = module:get_option_string("debug_traceback_filename", default_filename_template);
local signal_name = module:get_option_string("debug_traceback_signal", "SIGUSR1");
function dump_traceback()
module:log("info", "Received %s, writing traceback", signal_name);
local tb = traceback();
module:fire_event("debug_traceback/triggered", { traceback = tb });
local f, err = io.open(get_filename(filename_template), "a+");
if not f then
module:log("error", "Unable to write traceback: %s", err);
return;
end
f:write("-- Traceback generated at ", os.date("%b %d %H:%M:%S"), " --\n");
f:write(tb, "\n");
f:write("-- End of traceback --\n");
f:close();
count = count + 1;
end
local mod_posix = module:depends("posix");
if rawget(mod_posix, "features") and mod_posix.features.signal_events then
module:hook("signal/"..signal_name, dump_traceback);
else
require"util.signal".signal(signal_name, dump_traceback);
end

View File

@@ -0,0 +1,280 @@
local unpack = table.unpack or unpack;
local interpolation = require "util.interpolation";
local template = interpolation.new("%b$$", function (s) return ("%q"):format(s) end);
--luacheck: globals meta idsafe
local action_handlers = {};
-- Takes an XML string and returns a code string that builds that stanza
-- using st.stanza()
local function compile_xml(data)
local code = {};
local first, short_close = true, nil;
for tagline, text in data:gmatch("<([^>]+)>([^<]*)") do
if tagline:sub(-1,-1) == "/" then
tagline = tagline:sub(1, -2);
short_close = true;
end
if tagline:sub(1,1) == "/" then
code[#code+1] = (":up()");
else
local name, attr = tagline:match("^(%S*)%s*(.*)$");
local attr_str = {};
for k, _, v in attr:gmatch("(%S+)=([\"'])([^%2]-)%2") do
if #attr_str == 0 then
table.insert(attr_str, ", { ");
else
table.insert(attr_str, ", ");
end
if k:find("^%a%w*$") then
table.insert(attr_str, string.format("%s = %q", k, v));
else
table.insert(attr_str, string.format("[%q] = %q", k, v));
end
end
if #attr_str > 0 then
table.insert(attr_str, " }");
end
if first then
code[#code+1] = (string.format("st.stanza(%q %s)", name, #attr_str>0 and table.concat(attr_str) or ", nil"));
first = nil;
else
code[#code+1] = (string.format(":tag(%q%s)", name, table.concat(attr_str)));
end
end
if text and text:find("%S") then
code[#code+1] = (string.format(":text(%q)", text));
elseif short_close then
short_close = nil;
code[#code+1] = (":up()");
end
end
return table.concat(code, "");
end
function action_handlers.PASS()
return "do return pass_return end"
end
function action_handlers.DROP()
return "do return true end";
end
function action_handlers.DEFAULT()
return "do return false end";
end
function action_handlers.RETURN()
return "do return end"
end
function action_handlers.STRIP(tag_desc)
local code = {};
local name, xmlns = tag_desc:match("^(%S+) (.+)$");
if not name then
name, xmlns = tag_desc, nil;
end
if name == "*" then
name = nil;
end
code[#code+1] = ("local stanza_xmlns = stanza.attr.xmlns; ");
code[#code+1] = "stanza:maptags(function (tag) if ";
if name then
code[#code+1] = ("tag.name == %q and "):format(name);
end
if xmlns then
code[#code+1] = ("(tag.attr.xmlns or stanza_xmlns) == %q "):format(xmlns);
else
code[#code+1] = ("tag.attr.xmlns == stanza_xmlns ");
end
code[#code+1] = "then return nil; end return tag; end );";
return table.concat(code);
end
function action_handlers.INJECT(tag)
return "stanza:add_child("..compile_xml(tag)..")", { "st" };
end
local error_types = {
["bad-request"] = "modify";
["conflict"] = "cancel";
["feature-not-implemented"] = "cancel";
["forbidden"] = "auth";
["gone"] = "cancel";
["internal-server-error"] = "cancel";
["item-not-found"] = "cancel";
["jid-malformed"] = "modify";
["not-acceptable"] = "modify";
["not-allowed"] = "cancel";
["not-authorized"] = "auth";
["payment-required"] = "auth";
["policy-violation"] = "modify";
["recipient-unavailable"] = "wait";
["redirect"] = "modify";
["registration-required"] = "auth";
["remote-server-not-found"] = "cancel";
["remote-server-timeout"] = "wait";
["resource-constraint"] = "wait";
["service-unavailable"] = "cancel";
["subscription-required"] = "auth";
["undefined-condition"] = "cancel";
["unexpected-request"] = "wait";
};
local function route_modify(make_new, to, drop)
local reroute, deps = "session.send(newstanza)", { "st" };
if to then
reroute = ("newstanza.attr.to = %q; core_post_stanza(session, newstanza)"):format(to);
deps[#deps+1] = "core_post_stanza";
end
return ([[do local newstanza = st.%s; %s;%s end]])
:format(make_new, reroute, drop and " return true" or ""), deps;
end
function action_handlers.BOUNCE(with)
local error = with and with:match("^%S+") or "service-unavailable";
local error_type = error:match(":(%S+)");
if not error_type then
error_type = error_types[error] or "cancel";
else
error = error:match("^[^:]+");
end
error, error_type = string.format("%q", error), string.format("%q", error_type);
local text = with and with:match(" %((.+)%)$");
if text then
text = string.format("%q", text);
else
text = "nil";
end
local route_modify_code, deps = route_modify(("error_reply(stanza, %s, %s, %s)"):format(error_type, error, text), nil, true);
deps[#deps+1] = "type";
deps[#deps+1] = "name";
return [[if type == "error" or (name == "iq" and type == "result") then return true; end -- Don't reply to 'error' stanzas, or iq results
]]..route_modify_code, deps;
end
function action_handlers.REDIRECT(where)
return route_modify("clone(stanza)", where, true);
end
function action_handlers.COPY(where)
return route_modify("clone(stanza)", where, false);
end
function action_handlers.REPLY(with)
return route_modify(("reply(stanza):body(%q)"):format(with));
end
function action_handlers.FORWARD(where)
local code = [[
local newstanza = st.stanza("message", { to = %q, from = current_host }):tag("forwarded", { xmlns = "urn:xmpp:forward:0" });
local tmp_stanza = st.clone(stanza); tmp_stanza.attr.xmlns = "jabber:client"; newstanza:add_child(tmp_stanza);
core_post_stanza(session, newstanza);
]];
return code:format(where), { "core_post_stanza", "current_host" };
end
function action_handlers.LOG(string)
local level = string:match("^%[(%a+)%]") or "info";
string = string:gsub("^%[%a+%] ?", "");
local meta_deps = {};
local code = meta(("(session.log or log)(%q, '%%s', %q);"):format(level, string), meta_deps);
return code, meta_deps;
end
function action_handlers.RULEDEP(dep)
return "", { dep };
end
function action_handlers.EVENT(name)
return ("fire_event(%q, event)"):format(name);
end
function action_handlers.JUMP_EVENT(name)
return ("do return fire_event(%q, event); end"):format(name);
end
function action_handlers.JUMP_CHAIN(name)
return template([[do
local ret = fire_event($chain_event$, event);
if ret ~= nil then
if ret == false then
log("debug", "Chain %q accepted stanza (ret %s)", $chain_name$, tostring(ret));
return pass_return;
end
log("debug", "Chain %q rejected stanza (ret %s)", $chain_name$, tostring(ret));
return ret;
end
end]], { chain_event = "firewall/chains/"..name, chain_name = name });
end
function action_handlers.MARK_ORIGIN(name)
return [[session.firewall_marked_]]..idsafe(name)..[[ = current_timestamp;]], { "timestamp" };
end
function action_handlers.UNMARK_ORIGIN(name)
return [[session.firewall_marked_]]..idsafe(name)..[[ = nil;]]
end
function action_handlers.MARK_USER(name)
return ([[if session.username and session.host == current_host then
fire_event("firewall/marked/user", {
username = session.username;
mark = %q;
timestamp = current_timestamp;
});
else
log("warn", "Attempt to MARK a remote user - only local users may be marked");
end]]):format(assert(idsafe(name), "Invalid characters in mark name: "..name)), {
"current_host";
"timestamp";
};
end
function action_handlers.UNMARK_USER(name)
return ([[if session.username and session.host == current_host then
fire_event("firewall/unmarked/user", {
username = session.username;
mark = %q;
});
else
log("warn", "Attempt to UNMARK a remote user - only local users may be marked");
end]]):format(assert(idsafe(name), "Invalid characters in mark name: "..name));
end
function action_handlers.ADD_TO(spec)
local list_name, value = spec:match("(%S+) (.+)");
local meta_deps = {};
value = meta(("%q"):format(value), meta_deps);
return ("list_%s:add(%s);"):format(list_name, value), { "list:"..list_name, unpack(meta_deps) };
end
function action_handlers.UNSUBSCRIBE_SENDER()
return "rostermanager.unsubscribed(to_node, to_host, bare_from);\
rostermanager.roster_push(to_node, to_host, bare_from);\
core_post_stanza(session, st.presence({ from = bare_to, to = bare_from, type = \"unsubscribed\" }));",
{ "rostermanager", "core_post_stanza", "st", "split_to", "bare_to", "bare_from" };
end
function action_handlers.REPORT_TO(spec)
local where, reason, text = spec:match("^%s*(%S+) *(%S*) *(.*)$");
if reason == "spam" then
reason = "urn:xmpp:reporting:spam";
elseif reason == "abuse" or not reason then
reason = "urn:xmpp:reporting:abuse";
end
local code = [[
local newstanza = st.stanza("message", { to = %q, from = current_host }):tag("forwarded", { xmlns = "urn:xmpp:forward:0" });
local tmp_stanza = st.clone(stanza); tmp_stanza.attr.xmlns = "jabber:client"; newstanza:add_child(tmp_stanza):up();
newstanza:tag("report", { xmlns = "urn:xmpp:reporting:1", reason = %q })
do local text = %q; if text ~= "" then newstanza:text_tag("text", text); end end
newstanza:up();
core_post_stanza(session, newstanza);
]];
return code:format(where, reason, text), { "core_post_stanza", "current_host", "st" };
end
return action_handlers;

View File

@@ -0,0 +1,384 @@
--luacheck: globals meta idsafe
local condition_handlers = {};
local jid = require "util.jid";
local unpack = table.unpack or unpack;
-- Helper to convert user-input strings (yes/true//no/false) to a bool
local function string_to_boolean(s)
s = s:lower();
return s == "yes" or s == "true";
end
-- Return a code string for a condition that checks whether the contents
-- of variable with the name 'name' matches any of the values in the
-- comma/space/pipe delimited list 'values'.
local function compile_comparison_list(name, values)
local conditions = {};
for value in values:gmatch("[^%s,|]+") do
table.insert(conditions, ("%s == %q"):format(name, value));
end
return table.concat(conditions, " or ");
end
function condition_handlers.KIND(kind)
assert(kind, "Expected stanza kind to match against");
return compile_comparison_list("name", kind), { "name" };
end
local wildcard_equivs = { ["*"] = ".*", ["?"] = "." };
local function compile_jid_match_part(part, match)
if not match then
return part.." == nil";
end
local pattern = match:match("^<(.*)>$");
if pattern then
if pattern == "*" then
return part;
end
if pattern:find("^<.*>$") then
pattern = pattern:match("^<(.*)>$");
else
pattern = pattern:gsub("%p", "%%%0"):gsub("%%(%p)", wildcard_equivs);
end
return ("(%s and %s:find(%q))"):format(part, part, "^"..pattern.."$");
else
return ("%s == %q"):format(part, match);
end
end
local function compile_jid_match(which, match_jid)
local match_node, match_host, match_resource = jid.split(match_jid);
local conditions = {};
conditions[#conditions+1] = compile_jid_match_part(which.."_node", match_node);
conditions[#conditions+1] = compile_jid_match_part(which.."_host", match_host);
if match_resource then
conditions[#conditions+1] = compile_jid_match_part(which.."_resource", match_resource);
end
return table.concat(conditions, " and ");
end
function condition_handlers.TO(to)
return compile_jid_match("to", to), { "split_to" };
end
function condition_handlers.FROM(from)
return compile_jid_match("from", from), { "split_from" };
end
function condition_handlers.FROM_FULL_JID()
return "not "..compile_jid_match_part("from_resource", nil), { "split_from" };
end
function condition_handlers.FROM_EXACTLY(from)
local metadeps = {};
return ("from == %s"):format(metaq(from, metadeps)), { "from", unpack(metadeps) };
end
function condition_handlers.TO_EXACTLY(to)
local metadeps = {};
return ("to == %s"):format(metaq(to, metadeps)), { "to", unpack(metadeps) };
end
function condition_handlers.TO_SELF()
-- Intentionally not using 'to' here, as that defaults to bare JID when nil
return ("stanza.attr.to == nil");
end
function condition_handlers.TYPE(type)
assert(type, "Expected 'type' value to match against");
return compile_comparison_list("(type or (name == 'message' and 'normal') or (name == 'presence' and 'available'))", type), { "type", "name" };
end
local function zone_check(zone, which)
local zone_var = zone;
if zone == "$local" then zone_var = "_local" end
local which_not = which == "from" and "to" or "from";
return ("(zone_%s[%s_host] or zone_%s[%s] or zone_%s[bare_%s]) "
.."and not(zone_%s[%s_host] or zone_%s[%s] or zone_%s[bare_%s])"
)
:format(zone_var, which, zone_var, which, zone_var, which,
zone_var, which_not, zone_var, which_not, zone_var, which_not), {
"split_to", "split_from", "bare_to", "bare_from", "zone:"..zone
};
end
function condition_handlers.ENTERING(zone)
return zone_check(zone, "to");
end
function condition_handlers.LEAVING(zone)
return zone_check(zone, "from");
end
-- IN ROSTER? (parameter is deprecated)
function condition_handlers.IN_ROSTER(yes_no)
local in_roster_requirement = string_to_boolean(yes_no or "yes"); -- COMPAT w/ older scripts
return "not "..(in_roster_requirement and "not" or "").." roster_entry", { "roster_entry" };
end
function condition_handlers.IN_ROSTER_GROUP(group)
return ("not not (roster_entry and roster_entry.groups[%q])"):format(group), { "roster_entry" };
end
function condition_handlers.SUBSCRIBED()
return "(bare_to == bare_from or to_node and rostermanager.is_contact_subscribed(to_node, to_host, bare_from))",
{ "rostermanager", "split_to", "bare_to", "bare_from" };
end
function condition_handlers.PENDING_SUBSCRIPTION_FROM_SENDER()
return "(bare_to == bare_from or to_node and rostermanager.is_contact_pending_in(to_node, to_host, bare_from))",
{ "rostermanager", "split_to", "bare_to", "bare_from" };
end
function condition_handlers.PAYLOAD(payload_ns)
return ("stanza:get_child(nil, %q)"):format(payload_ns);
end
function condition_handlers.INSPECT(path)
if path:find("=") then
local query, match_type, value = path:match("(.-)([~/$]*)=(.*)");
if not(query:match("#$") or query:match("@[^/]+")) then
error("Stanza path does not return a string (append # for text content or @name for value of named attribute)", 0);
end
local meta_deps = {};
local quoted_value = ("%q"):format(value);
if match_type:find("$", 1, true) then
match_type = match_type:gsub("%$", "");
quoted_value = meta(quoted_value, meta_deps);
end
if match_type == "~" then -- Lua pattern match
return ("(stanza:find(%q) or ''):match(%s)"):format(query, quoted_value), meta_deps;
elseif match_type == "/" then -- find literal substring
return ("(stanza:find(%q) or ''):find(%s, 1, true)"):format(query, quoted_value), meta_deps;
elseif match_type == "" then -- exact match
return ("stanza:find(%q) == %s"):format(query, quoted_value), meta_deps;
else
error("Unrecognised comparison '"..match_type.."='", 0);
end
end
return ("stanza:find(%q)"):format(path);
end
function condition_handlers.FROM_GROUP(group_name)
return ("group_contains(%q, bare_from)"):format(group_name), { "group_contains", "bare_from" };
end
function condition_handlers.TO_GROUP(group_name)
return ("group_contains(%q, bare_to)"):format(group_name), { "group_contains", "bare_to" };
end
function condition_handlers.CROSSING_GROUPS(group_names)
local code = {};
for group_name in group_names:gmatch("([^, ][^,]+)") do
group_name = group_name:match("^%s*(.-)%s*$"); -- Trim leading/trailing whitespace
-- Just check that's it is crossing from outside group to inside group
table.insert(code, ("(group_contains(%q, bare_to) and group_contains(%q, bare_from))"):format(group_name, group_name))
end
return "not "..table.concat(code, " or "), { "group_contains", "bare_to", "bare_from" };
end
-- COMPAT w/0.12: Deprecated
function condition_handlers.FROM_ADMIN_OF(host)
return ("is_admin(bare_from, %s)"):format(host ~= "*" and metaq(host) or nil), { "is_admin", "bare_from" };
end
-- COMPAT w/0.12: Deprecated
function condition_handlers.TO_ADMIN_OF(host)
return ("is_admin(bare_to, %s)"):format(host ~= "*" and metaq(host) or nil), { "is_admin", "bare_to" };
end
-- COMPAT w/0.12: Deprecated
function condition_handlers.FROM_ADMIN()
return ("is_admin(bare_from, current_host)"), { "is_admin", "bare_from", "current_host" };
end
-- COMPAT w/0.12: Deprecated
function condition_handlers.TO_ADMIN()
return ("is_admin(bare_to, current_host)"), { "is_admin", "bare_to", "current_host" };
end
-- MAY: permission_to_check
function condition_handlers.MAY(permission_to_check)
return ("module:may(%q, event)"):format(permission_to_check);
end
function condition_handlers.TO_ROLE(role_name)
return ("get_jid_role(bare_to, current_host) == %q"):format(role_name), { "get_jid_role", "current_host", "bare_to" };
end
function condition_handlers.FROM_ROLE(role_name)
return ("get_jid_role(bare_from, current_host) == %q"):format(role_name), { "get_jid_role", "current_host", "bare_from" };
end
local day_numbers = { sun = 0, mon = 2, tue = 3, wed = 4, thu = 5, fri = 6, sat = 7 };
local function current_time_check(op, hour, minute)
hour, minute = tonumber(hour), tonumber(minute);
local adj_op = op == "<" and "<" or ">="; -- Start time inclusive, end time exclusive
if minute == 0 then
return "(current_hour"..adj_op..hour..")";
else
return "((current_hour"..op..hour..") or (current_hour == "..hour.." and current_minute"..adj_op..minute.."))";
end
end
local function resolve_day_number(day_name)
return assert(day_numbers[day_name:sub(1,3):lower()], "Unknown day name: "..day_name);
end
function condition_handlers.DAY(days)
local conditions = {};
for day_range in days:gmatch("[^,]+") do
local day_start, day_end = day_range:match("(%a+)%s*%-%s*(%a+)");
if day_start and day_end then
local day_start_num, day_end_num = resolve_day_number(day_start), resolve_day_number(day_end);
local op = "and";
if day_end_num < day_start_num then
op = "or";
end
table.insert(conditions, ("current_day >= %d %s current_day <= %d"):format(day_start_num, op, day_end_num));
elseif day_range:find("%a") then
local day = resolve_day_number(day_range:match("%a+"));
table.insert(conditions, "current_day == "..day);
else
error("Unable to parse day/day range: "..day_range);
end
end
assert(#conditions>0, "Expected a list of days or day ranges");
return "("..table.concat(conditions, ") or (")..")", { "time:day" };
end
function condition_handlers.TIME(ranges)
local conditions = {};
for range in ranges:gmatch("([^,]+)") do
local clause = {};
range = range:lower()
:gsub("(%d+):?(%d*) *am", function (h, m) return tostring(tonumber(h)%12)..":"..(tonumber(m) or "00"); end)
:gsub("(%d+):?(%d*) *pm", function (h, m) return tostring(tonumber(h)%12+12)..":"..(tonumber(m) or "00"); end);
local start_hour, start_minute = range:match("(%d+):(%d+) *%-");
local end_hour, end_minute = range:match("%- *(%d+):(%d+)");
local op = tonumber(start_hour) > tonumber(end_hour) and " or " or " and ";
if start_hour and end_hour then
table.insert(clause, current_time_check(">", start_hour, start_minute));
table.insert(clause, current_time_check("<", end_hour, end_minute));
end
if #clause == 0 then
error("Unable to parse time range: "..range);
end
table.insert(conditions, "("..table.concat(clause, " "..op.." ")..")");
end
return table.concat(conditions, " or "), { "time:hour,min" };
end
function condition_handlers.LIMIT(spec)
local name, param = spec:match("^(%w+) on (.+)$");
local meta_deps = {};
if not name then
name = spec:match("^%w+$");
if not name then
error("Unable to parse LIMIT specification");
end
else
param = meta(("%q"):format(param), meta_deps);
end
if not param then
return ("not global_throttle_%s:poll(1)"):format(name), { "globalthrottle:"..name, unpack(meta_deps) };
end
return ("not multi_throttle_%s:poll_on(%s, 1)"):format(name, param), { "multithrottle:"..name, unpack(meta_deps) };
end
function condition_handlers.ORIGIN_MARKED(name_and_time)
local name, time = name_and_time:match("^%s*([%w_]+)%s+%(([^)]+)s%)%s*$");
if not name then
name = name_and_time:match("^%s*([%w_]+)%s*$");
end
if not name then
error("Error parsing mark name, see documentation for usage examples");
end
if time then
return ("(current_timestamp - (session.firewall_marked_%s or 0)) < %d"):format(idsafe(name), tonumber(time)), { "timestamp" };
end
return ("not not session.firewall_marked_"..idsafe(name));
end
function condition_handlers.USER_MARKED(name_and_time)
local name, time = name_and_time:match("^%s*([%w_]+)%s+%(([^)]+)s%)%s*$");
if not name then
name = name_and_time:match("^%s*([%w_]+)%s*$");
end
if not name then
error("Error parsing mark name, see documentation for usage examples");
end
if time then
return ([[(
current_timestamp - (session.firewall_marks and session.firewall_marks.%s or 0)
) < %d]]):format(idsafe(name), tonumber(time)), { "timestamp" };
end
return ("not not (session.firewall_marks and session.firewall_marks."..idsafe(name)..")");
end
function condition_handlers.SENT_DIRECTED_PRESENCE_TO_SENDER()
return "not not (session.directed and session.directed[from])", { "from" };
end
-- TO FULL JID?
function condition_handlers.TO_FULL_JID()
return "not not full_sessions[to]", { "to", "full_sessions" };
end
-- CHECK LIST: spammers contains $<@from>
function condition_handlers.CHECK_LIST(list_condition)
local list_name, expr = list_condition:match("(%S+) contains (.+)$");
if not (list_name and expr) then
error("Error parsing list check, syntax: LISTNAME contains EXPRESSION");
end
local meta_deps = {};
expr = meta(("%q"):format(expr), meta_deps);
return ("list_%s:contains(%s) == true"):format(list_name, expr), { "list:"..list_name, unpack(meta_deps) };
end
-- SCAN: body for word in badwords
function condition_handlers.SCAN(scan_expression)
local search_name, pattern_name, list_name = scan_expression:match("(%S+) for (%S+) in (%S+)$");
if not (search_name) then
error("Error parsing SCAN expression, syntax: SEARCH for PATTERN in LIST");
end
return ("scan_list(list_%s, %s)"):format(
list_name,
"tokens_"..search_name.."_"..pattern_name
), {
"scan_list",
"tokens:"..search_name.."-"..pattern_name, "list:"..list_name
};
end
-- COUNT: lines in body < 10
local valid_comp_ops = { [">"] = ">", ["<"] = "<", ["="] = "==", ["=="] = "==", ["<="] = "<=", [">="] = ">=" };
function condition_handlers.COUNT(count_expression)
local pattern_name, search_name, comparator_expression = count_expression:match("(%S+) in (%S+) (.+)$");
if not (pattern_name) then
error("Error parsing COUNT expression, syntax: PATTERN in SEARCH COMPARATOR");
end
local value;
comparator_expression = comparator_expression:gsub("%d+", function (value_string)
value = tonumber(value_string);
return "";
end);
if not value then
error("Error parsing COUNT expression, expected value");
end
local comp_op = comparator_expression:gsub("%s+", "");
assert(valid_comp_ops[comp_op], "Error parsing COUNT expression, unknown comparison operator: "..comp_op);
return ("it_count(search_%s:gmatch(pattern_%s)) %s %d"):format(
search_name, pattern_name, comp_op, value
), {
"it_count",
"search:"..search_name, "pattern:"..pattern_name
};
end
return condition_handlers;

View File

@@ -0,0 +1,335 @@
-- Name arguments are unused here
-- luacheck: ignore 212
local definition_handlers = {};
local http = require "net.http";
local timer = require "util.timer";
local set = require"util.set";
local new_throttle = require "util.throttle".create;
local hashes = require "util.hashes";
local jid = require "util.jid";
local lfs = require "lfs";
local multirate_cache_size = module:get_option_number("firewall_multirate_cache_limit", 1000);
function definition_handlers.ZONE(zone_name, zone_members)
local zone_member_list = {};
for member in zone_members:gmatch("[^, ]+") do
zone_member_list[#zone_member_list+1] = member;
end
return set.new(zone_member_list)._items;
end
-- Helper function used by RATE handler
local function evict_only_unthrottled(name, throttle)
throttle:update();
-- Check whether the throttle is at max balance (i.e. totally safe to forget about it)
if throttle.balance < throttle.max then
-- Not safe to forget
return false;
end
end
function definition_handlers.RATE(name, line)
local rate = assert(tonumber(line:match("([%d.]+)")), "Unable to parse rate");
local burst = tonumber(line:match("%(%s*burst%s+([%d.]+)%s*%)")) or 1;
local max_throttles = tonumber(line:match("%(%s*entries%s+([%d]+)%s*%)")) or multirate_cache_size;
local deny_when_full = not line:match("%(allow overflow%)");
return {
single = function ()
return new_throttle(rate*burst, burst);
end;
multi = function ()
local cache = require "util.cache".new(max_throttles, deny_when_full and evict_only_unthrottled or nil);
return {
poll_on = function (_, key, amount)
assert(key, "no key");
local throttle = cache:get(key);
if not throttle then
throttle = new_throttle(rate*burst, burst);
if not cache:set(key, throttle) then
module:log("warn", "Multirate '%s' has hit its maximum number of active throttles (%d), denying new events", name, max_throttles);
return false;
end
end
return throttle:poll(amount);
end;
}
end;
};
end
local list_backends = {
-- %LIST name: memory (limit: number)
memory = {
init = function (self, type, opts)
if opts.limit then
local have_cache_lib, cache_lib = pcall(require, "util.cache");
if not have_cache_lib then
error("In-memory lists with a size limit require Prosody 0.10");
end
self.cache = cache_lib.new((assert(tonumber(opts.limit), "Invalid list limit")));
if not self.cache.table then
error("In-memory lists with a size limit require a newer version of Prosody 0.10");
end
self.items = self.cache:table();
else
self.items = {};
end
end;
add = function (self, item)
self.items[item] = true;
end;
remove = function (self, item)
self.items[item] = nil;
end;
contains = function (self, item)
return self.items[item] == true;
end;
};
-- %LIST name: http://example.com/ (ttl: number, pattern: pat, hash: sha1)
http = {
init = function (self, url, opts)
local poll_interval = assert(tonumber(opts.ttl or "3600"), "invalid ttl for <"..url.."> (expected number of seconds)");
local pattern = opts.pattern or "([^\r\n]+)\r?\n";
assert(pcall(string.match, "", pattern), "invalid pattern for <"..url..">");
if opts.hash then
assert(opts.hash:match("^%w+$") and type(hashes[opts.hash]) == "function", "invalid hash function: "..opts.hash);
self.hash_function = hashes[opts.hash];
end
local etag;
local failure_count = 0;
local retry_intervals = { 60, 120, 300 };
-- By default only check the certificate if net.http supports SNI
local sni_supported = http.feature and http.features.sni;
local insecure = false;
if opts.checkcert == "never" then
insecure = true;
elseif (opts.checkcert == nil or opts.checkcert == "when-sni") and not sni_supported then
insecure = false;
end
local function update_list()
http.request(url, {
insecure = insecure;
headers = {
["If-None-Match"] = etag;
};
}, function (body, code, response)
local next_poll = poll_interval;
if code == 200 and body then
etag = response.headers.etag;
local items = {};
for entry in body:gmatch(pattern) do
items[entry] = true;
end
self.items = items;
module:log("debug", "Fetched updated list from <%s>", url);
elseif code == 304 then
module:log("debug", "List at <%s> is unchanged", url);
elseif code == 0 or (code >= 400 and code <=599) then
module:log("warn", "Failed to fetch list from <%s>: %d %s", url, code, tostring(body));
failure_count = failure_count + 1;
next_poll = retry_intervals[failure_count] or retry_intervals[#retry_intervals];
end
if next_poll > 0 then
timer.add_task(next_poll+math.random(0, 60), update_list);
end
end);
end
update_list();
end;
add = function ()
end;
remove = function ()
end;
contains = function (self, item)
if self.hash_function then
item = self.hash_function(item);
end
return self.items and self.items[item] == true;
end;
};
-- %LIST: file:/path/to/file
file = {
init = function (self, file_spec, opts)
local n, items = 0, {};
self.items = items;
local filename = file_spec:gsub("^file:", "");
if opts.missing == "ignore" and not lfs.attributes(filename, "mode") then
module:log("debug", "Ignoring missing list file: %s", filename);
return;
end
local file, err = io.open(filename);
if not file then
module:log("warn", "Failed to open list from %s: %s", filename, err);
return;
else
for line in file:lines() do
if not items[line] then
n = n + 1;
items[line] = true;
end
end
end
module:log("debug", "Loaded %d items from %s", n, filename);
end;
add = function (self, item)
self.items[item] = true;
end;
remove = function (self, item)
self.items[item] = nil;
end;
contains = function (self, item)
return self.items and self.items[item] == true;
end;
};
-- %LIST: pubsub:pubsub.example.com/node
-- TODO or the actual URI scheme? Bit overkill maybe?
-- TODO Publish items back to the service?
-- Step 1: Receiving pubsub events and storing them in the list
-- We'll start by using only the item id.
-- TODO Invent some custom schema for this? Needed for just a set of strings?
pubsubitemid = {
init = function(self, pubsub_spec, opts)
local service_addr, node = pubsub_spec:match("^pubsubitemid:([^/]*)/(.*)");
if not service_addr then
module:log("warn", "Invalid list specification (expected 'pubsubitemid:<service>/<node>', got: '%s')", pubsub_spec);
return;
end
module:depends("pubsub_subscription");
module:add_item("pubsub-subscription", {
service = service_addr;
node = node;
on_subscribed = function ()
self.items = {};
end;
on_item = function (event)
self:add(event.item.attr.id);
end;
on_retract = function (event)
self:remove(event.item.attr.id);
end;
on_purge = function ()
self.items = {};
end;
on_unsubscribed = function ()
self.items = nil;
end;
on_delete= function ()
self.items = nil;
end;
});
-- TODO Initial fetch? Or should mod_pubsub_subscription do this?
end;
add = function (self, item)
if self.items then
self.items[item] = true;
end
end;
remove = function (self, item)
if self.items then
self.items[item] = nil;
end
end;
contains = function (self, item)
return self.items and self.items[item] == true;
end;
};
};
list_backends.https = list_backends.http;
local normalize_functions = {
upper = string.upper, lower = string.lower;
md5 = hashes.md5, sha1 = hashes.sha1, sha256 = hashes.sha256;
prep = jid.prep, bare = jid.bare;
};
local function wrap_list_method(list_method, filter)
return function (self, item)
return list_method(self, filter(item));
end
end
local function create_list(list_backend, list_def, opts)
if not list_backends[list_backend] then
error("Unknown list type '"..list_backend.."'", 0);
end
local list = setmetatable({}, { __index = list_backends[list_backend] });
if list.init then
list:init(list_def, opts);
end
if opts.filter then
local filters = {};
for func_name in opts.filter:gmatch("[%w_]+") do
if func_name == "log" then
table.insert(filters, function (s)
--print("&&&&&", s);
module:log("debug", "Checking list <%s> for: %s", list_def, s);
return s;
end);
else
assert(normalize_functions[func_name], "Unknown list filter: "..func_name);
table.insert(filters, normalize_functions[func_name]);
end
end
local filter;
local n = #filters;
if n == 1 then
filter = filters[1];
else
function filter(s)
for i = 1, n do
s = filters[i](s or "");
end
return s;
end
end
list.add = wrap_list_method(list.add, filter);
list.remove = wrap_list_method(list.remove, filter);
list.contains = wrap_list_method(list.contains, filter);
end
return list;
end
--[[
%LIST spammers: memory (source: /etc/spammers.txt)
%LIST spammers: memory (source: /etc/spammers.txt)
%LIST spammers: http://example.com/blacklist.txt
]]
function definition_handlers.LIST(list_name, list_definition)
local list_backend = list_definition:match("^%w+");
local opts = {};
local opt_string = list_definition:match("^%S+%s+%((.+)%)");
if opt_string then
for opt_k, opt_v in opt_string:gmatch("(%w+): ?([^,]+)") do
opts[opt_k] = opt_v;
end
end
return create_list(list_backend, list_definition:match("^%S+"), opts);
end
function definition_handlers.PATTERN(name, pattern)
local ok, err = pcall(string.match, "", pattern);
if not ok then
error("Invalid pattern '"..name.."': "..err);
end
return pattern;
end
function definition_handlers.SEARCH(name, pattern)
return pattern;
end
return definition_handlers;

View File

@@ -0,0 +1,35 @@
local mark_storage = module:open_store("firewall_marks");
local mark_map_storage = module:open_store("firewall_marks", "map");
local user_sessions = prosody.hosts[module.host].sessions;
module:hook("firewall/marked/user", function (event)
local user = user_sessions[event.username];
local marks = user and user.firewall_marks;
if user and not marks then
-- Load marks from storage to cache on the user object
marks = mark_storage:get(event.username) or {};
user.firewall_marks = marks; --luacheck: ignore 122
end
if marks then
marks[event.mark] = event.timestamp;
end
local ok, err = mark_map_storage:set(event.username, event.mark, event.timestamp);
if not ok then
module:log("error", "Failed to mark user %q with %q: %s", event.username, event.mark, err);
end
return true;
end, -1);
module:hook("firewall/unmarked/user", function (event)
local user = user_sessions[event.username];
local marks = user and user.firewall_marks;
if marks then
marks[event.mark] = nil;
end
local ok, err = mark_map_storage:set(event.username, event.mark, nil);
if not ok then
module:log("error", "Failed to unmark user %q with %q: %s", event.username, event.mark, err);
end
return true;
end, -1);

View File

@@ -0,0 +1,784 @@
local lfs = require "lfs";
local resolve_relative_path = require "core.configmanager".resolve_relative_path;
local envload = require "util.envload".envload;
local logger = require "util.logger".init;
local it = require "util.iterators";
local set = require "util.set";
local have_features, features = pcall(require, "core.features");
features = have_features and features.available or set.new();
-- [definition_type] = definition_factory(param)
local definitions = module:shared("definitions");
-- When a definition instance has been instantiated, it lives here
-- [definition_type][definition_name] = definition_object
local active_definitions = {
ZONE = {
-- Default zone that includes all local hosts
["$local"] = setmetatable({}, { __index = prosody.hosts });
};
};
local default_chains = {
preroute = {
type = "event";
priority = 0.1;
"pre-message/bare", "pre-message/full", "pre-message/host";
"pre-presence/bare", "pre-presence/full", "pre-presence/host";
"pre-iq/bare", "pre-iq/full", "pre-iq/host";
};
deliver = {
type = "event";
priority = 0.1;
"message/bare", "message/full", "message/host";
"presence/bare", "presence/full", "presence/host";
"iq/bare", "iq/full", "iq/host";
};
deliver_remote = {
type = "event"; "route/remote";
priority = 0.1;
};
};
local extra_chains = module:get_option("firewall_extra_chains", {});
local chains = {};
for k,v in pairs(default_chains) do
chains[k] = v;
end
for k,v in pairs(extra_chains) do
chains[k] = v;
end
-- Returns the input if it is safe to be used as a variable name, otherwise nil
function idsafe(name)
return name:match("^%a[%w_]*$");
end
local meta_funcs = {
bare = function (code)
return "jid_bare("..code..")", {"jid_bare"};
end;
node = function (code)
return "(jid_split("..code.."))", {"jid_split"};
end;
host = function (code)
return "(select(2, jid_split("..code..")))", {"jid_split"};
end;
resource = function (code)
return "(select(3, jid_split("..code..")))", {"jid_split"};
end;
};
-- Run quoted (%q) strings through this to allow them to contain code. e.g.: LOG=Received: $(stanza:top_tag())
function meta(s, deps, extra)
return (s:gsub("$(%b())", function (expr)
expr = expr:gsub("\\(.)", "%1");
return [["..tostring(]]..expr..[[).."]];
end)
:gsub("$(%b<>)", function (expr)
expr = expr:sub(2,-2);
local default = "<undefined>";
expr = expr:gsub("||(%b\"\")$", function (default_string)
default = stripslashes(default_string:sub(2,-2));
return "";
end);
local func_chain = expr:match("|[%w|]+$");
if func_chain then
expr = expr:sub(1, -1-#func_chain);
end
local code;
if expr:match("^@") then
-- Skip stanza:find() for simple attribute lookup
local attr_name = expr:sub(2);
if deps and (attr_name == "to" or attr_name == "from" or attr_name == "type") then
-- These attributes may be cached in locals
code = attr_name;
table.insert(deps, attr_name);
else
code = "stanza.attr["..("%q"):format(attr_name).."]";
end
elseif expr:match("^%w+#$") then
code = ("stanza:get_child_text(%q)"):format(expr:sub(1, -2));
else
code = ("stanza:find(%q)"):format(expr);
end
if func_chain then
for func_name in func_chain:gmatch("|(%w+)") do
-- to/from are already available in local variables, use those if possible
if (code == "to" or code == "from") and func_name == "bare" then
code = "bare_"..code;
table.insert(deps, code);
elseif (code == "to" or code == "from") and (func_name == "node" or func_name == "host" or func_name == "resource") then
table.insert(deps, "split_"..code);
code = code.."_"..func_name;
else
assert(meta_funcs[func_name], "unknown function: "..func_name);
local new_code, new_deps = meta_funcs[func_name](code);
code = new_code;
if new_deps and #new_deps > 0 then
assert(deps, "function not supported here: "..func_name);
for _, dep in ipairs(new_deps) do
table.insert(deps, dep);
end
end
end
end
end
return "\"..tostring("..code.." or "..("%q"):format(default)..")..\"";
end)
:gsub("$$(%a+)", extra or {})
:gsub([[^""%.%.]], "")
:gsub([[%.%.""$]], ""));
end
function metaq(s, ...)
return meta(("%q"):format(s), ...);
end
local escape_chars = {
a = "\a", b = "\b", f = "\f", n = "\n", r = "\r", t = "\t",
v = "\v", ["\\"] = "\\", ["\""] = "\"", ["\'"] = "\'"
};
function stripslashes(s)
return (s:gsub("\\(.)", escape_chars));
end
-- Dependency locations:
-- <type lib>
-- <type global>
-- function handler()
-- <local deps>
-- if <conditions> then
-- <actions>
-- end
-- end
local available_deps = {
st = { global_code = [[local st = require "util.stanza";]]};
it = { global_code = [[local it = require "util.iterators";]]};
it_count = { global_code = [[local it_count = it.count;]], depends = { "it" } };
current_host = { global_code = [[local current_host = module.host;]] };
jid_split = {
global_code = [[local jid_split = require "util.jid".split;]];
};
jid_bare = {
global_code = [[local jid_bare = require "util.jid".bare;]];
};
to = { local_code = [[local to = stanza.attr.to or jid_bare(session.full_jid);]]; depends = { "jid_bare" } };
from = { local_code = [[local from = stanza.attr.from;]] };
type = { local_code = [[local type = stanza.attr.type;]] };
name = { local_code = [[local name = stanza.name;]] };
split_to = { -- The stanza's split to address
depends = { "jid_split", "to" };
local_code = [[local to_node, to_host, to_resource = jid_split(to);]];
};
split_from = { -- The stanza's split from address
depends = { "jid_split", "from" };
local_code = [[local from_node, from_host, from_resource = jid_split(from);]];
};
bare_to = { depends = { "jid_bare", "to" }, local_code = "local bare_to = jid_bare(to)"};
bare_from = { depends = { "jid_bare", "from" }, local_code = "local bare_from = jid_bare(from)"};
group_contains = {
global_code = [[local group_contains = module:depends("groups").group_contains]];
};
is_admin = require"core.usermanager".is_admin and { global_code = [[local is_admin = require "core.usermanager".is_admin;]]} or nil;
get_jid_role = require "core.usermanager".get_jid_role and { global_code = [[local get_jid_role = require "core.usermanager".get_jid_role;]] } or nil;
core_post_stanza = { global_code = [[local core_post_stanza = prosody.core_post_stanza;]] };
zone = { global_code = function (zone)
local var = zone;
if var == "$local" then
var = "_local"; -- See #1090
else
assert(idsafe(var), "Invalid zone name: "..zone);
end
return ("local zone_%s = zones[%q] or {};"):format(var, zone);
end };
date_time = { global_code = [[local os_date = os.date]]; local_code = [[local current_date_time = os_date("*t");]] };
time = { local_code = function (what)
local defs = {};
for field in what:gmatch("%a+") do
table.insert(defs, ("local current_%s = current_date_time.%s;"):format(field, field));
end
return table.concat(defs, " ");
end, depends = { "date_time" }; };
timestamp = { global_code = [[local get_time = require "socket".gettime;]]; local_code = [[local current_timestamp = get_time();]]; };
globalthrottle = {
global_code = function (throttle)
assert(idsafe(throttle), "Invalid rate limit name: "..throttle);
assert(active_definitions.RATE[throttle], "Unknown rate limit: "..throttle);
return ("local global_throttle_%s = rates.%s:single();"):format(throttle, throttle);
end;
};
multithrottle = {
global_code = function (throttle)
assert(pcall(require, "util.cache"), "Using LIMIT with 'on' requires Prosody 0.10 or higher");
assert(idsafe(throttle), "Invalid rate limit name: "..throttle);
assert(active_definitions.RATE[throttle], "Unknown rate limit: "..throttle);
return ("local multi_throttle_%s = rates.%s:multi();"):format(throttle, throttle);
end;
};
full_sessions = {
global_code = [[local full_sessions = prosody.full_sessions;]];
};
rostermanager = {
global_code = [[local rostermanager = require "core.rostermanager";]];
};
roster_entry = {
local_code = [[local roster_entry = (to_node and rostermanager.load_roster(to_node, to_host) or {})[bare_from];]];
depends = { "rostermanager", "split_to", "bare_from" };
};
list = { global_code = function (list)
assert(idsafe(list), "Invalid list name: "..list);
assert(active_definitions.LIST[list], "Unknown list: "..list);
return ("local list_%s = lists[%q];"):format(list, list);
end
};
search = {
local_code = function (search_name)
local search_path = assert(active_definitions.SEARCH[search_name], "Undefined search path: "..search_name);
return ("local search_%s = tostring(stanza:find(%q) or \"\")"):format(search_name, search_path);
end;
};
pattern = {
local_code = function (pattern_name)
local pattern = assert(active_definitions.PATTERN[pattern_name], "Undefined pattern: "..pattern_name);
return ("local pattern_%s = %q"):format(pattern_name, pattern);
end;
};
tokens = {
local_code = function (search_and_pattern)
local search_name, pattern_name = search_and_pattern:match("^([^%-]+)-(.+)$");
local code = ([[local tokens_%s_%s = {};
if search_%s then
for s in search_%s:gmatch(pattern_%s) do
tokens_%s_%s[s] = true;
end
end
]]):format(search_name, pattern_name, search_name, search_name, pattern_name, search_name, pattern_name);
return code, { "search:"..search_name, "pattern:"..pattern_name };
end;
};
scan_list = {
global_code = [[local function scan_list(list, items) for item in pairs(items) do if list:contains(item) then return true; end end end]];
}
};
local function include_dep(dependency, code)
local dep, dep_param = dependency:match("^([^:]+):?(.*)$");
local dep_info = available_deps[dep];
if not dep_info then
module:log("error", "Dependency not found: %s", dep);
return;
end
if code.included_deps[dependency] ~= nil then
if code.included_deps[dependency] ~= true then
module:log("error", "Circular dependency on %s", dep);
end
return;
end
code.included_deps[dependency] = false; -- Pending flag (used to detect circular references)
for _, dep_dep in ipairs(dep_info.depends or {}) do
include_dep(dep_dep, code);
end
if dep_info.global_code then
if dep_param ~= "" then
local global_code, deps = dep_info.global_code(dep_param);
if deps then
for _, dep_dep in ipairs(deps) do
include_dep(dep_dep, code);
end
end
table.insert(code.global_header, global_code);
else
table.insert(code.global_header, dep_info.global_code);
end
end
if dep_info.local_code then
if dep_param ~= "" then
local local_code, deps = dep_info.local_code(dep_param);
if deps then
for _, dep_dep in ipairs(deps) do
include_dep(dep_dep, code);
end
end
table.insert(code, "\n\t\t-- "..dep.."\n\t\t"..local_code.."\n");
else
table.insert(code, "\n\t\t-- "..dep.."\n\t\t"..dep_info.local_code.."\n");
end
end
code.included_deps[dependency] = true;
end
local definition_handlers = module:require("definitions");
local condition_handlers = module:require("conditions");
local action_handlers = module:require("actions");
if module:get_option_boolean("firewall_experimental_user_marks", true) then
module:require"marks";
end
local function new_rule(ruleset, chain)
assert(chain, "no chain specified");
local rule = { conditions = {}, actions = {}, deps = {} };
table.insert(ruleset[chain], rule);
return rule;
end
local function parse_firewall_rules(filename)
local line_no = 0;
local function errmsg(err)
return "Error compiling "..filename.." on line "..line_no..": "..err;
end
local ruleset = {
deliver = {};
};
local chain = "deliver"; -- Default chain
local rule;
local file, err = io.open(filename);
if not file then return nil, err; end
local state; -- nil -> "rules" -> "actions" -> nil -> ...
local line_hold;
for line in file:lines() do
line = line:match("^%s*(.-)%s*$");
if line_hold and line:sub(-1,-1) ~= "\\" then
line = line_hold..line;
line_hold = nil;
elseif line:sub(-1,-1) == "\\" then
line_hold = (line_hold or "")..line:sub(1,-2);
end
line_no = line_no + 1;
if line_hold or line:find("^[#;]") then -- luacheck: ignore 542
-- No action; comment or partial line
elseif line == "" then
if state == "rules" then
return nil, ("Expected an action on line %d for preceding criteria")
:format(line_no);
end
state = nil;
elseif not(state) and line:sub(1, 2) == "::" then
chain = line:gsub("^::%s*", "");
local chain_info = chains[chain];
if not chain_info then
if chain:match("^user/") then
chains[chain] = { type = "event", priority = 1, pass_return = false };
else
return nil, errmsg("Unknown chain: "..chain);
end
elseif chain_info.type ~= "event" then
return nil, errmsg("Only event chains supported at the moment");
end
ruleset[chain] = ruleset[chain] or {};
elseif not(state) and line:sub(1,1) == "%" then -- Definition (zone, limit, etc.)
local what, name = line:match("^%%%s*([%w_]+) +([^ :]+)");
if not definition_handlers[what] then
return nil, errmsg("Definition of unknown object: "..what);
elseif not name or not idsafe(name) then
return nil, errmsg("Invalid "..what.." name");
end
local val = line:match(": ?(.*)$");
if not val and line:find(":<") then -- Read from file
local fn = line:match(":< ?(.-)%s*$");
if not fn then
return nil, errmsg("Unable to parse filename");
end
local f, err = io.open(fn);
if not f then return nil, errmsg(err); end
val = f:read("*a"):gsub("\r?\n", " "):gsub("%s+$", "");
end
if not val then
return nil, errmsg("No value given for definition");
end
val = stripslashes(val);
local ok, ret = pcall(definition_handlers[what], name, val);
if not ok then
return nil, errmsg(ret);
end
if not active_definitions[what] then
active_definitions[what] = {};
end
active_definitions[what][name] = ret;
elseif line:find("^[%w_ ]+[%.=]") then
-- Action
if state == nil then
-- This is a standalone action with no conditions
rule = new_rule(ruleset, chain);
end
state = "actions";
-- Action handlers?
local action = line:match("^[%w_ ]+"):upper():gsub(" ", "_");
if not action_handlers[action] then
return nil, ("Unknown action on line %d: %s"):format(line_no, action or "<unknown>");
end
table.insert(rule.actions, "-- "..line)
local ok, action_string, action_deps = pcall(action_handlers[action], line:match("=(.+)$"));
if not ok then
return nil, errmsg(action_string);
end
table.insert(rule.actions, action_string);
for _, dep in ipairs(action_deps or {}) do
table.insert(rule.deps, dep);
end
elseif state == "actions" then -- state is actions but action pattern did not match
state = nil; -- Awaiting next rule, etc.
table.insert(ruleset[chain], rule);
rule = nil;
else
if not state then
state = "rules";
rule = new_rule(ruleset, chain);
end
-- Check standard modifiers for the condition (e.g. NOT)
local negated;
local condition = line:match("^[^:=%.?]*");
if condition:find("%f[%w]NOT%f[^%w]") then
local s, e = condition:match("%f[%w]()NOT()%f[^%w]");
condition = (condition:sub(1,s-1)..condition:sub(e+1, -1)):match("^%s*(.-)%s*$");
negated = true;
end
condition = condition:gsub(" ", "_");
if not condition_handlers[condition] then
return nil, ("Unknown condition on line %d: %s"):format(line_no, (condition:gsub("_", " ")));
end
-- Get the code for this condition
local ok, condition_code, condition_deps = pcall(condition_handlers[condition], line:match(":%s?(.+)$"));
if not ok then
return nil, errmsg(condition_code);
end
if negated then condition_code = "not("..condition_code..")"; end
table.insert(rule.conditions, condition_code);
for _, dep in ipairs(condition_deps or {}) do
table.insert(rule.deps, dep);
end
end
end
return ruleset;
end
local function process_firewall_rules(ruleset)
-- Compile ruleset and return complete code
local chain_handlers = {};
-- Loop through the chains in the parsed ruleset (e.g. incoming, outgoing)
for chain_name, rules in pairs(ruleset) do
local code = { included_deps = {}, global_header = {} };
local condition_uses = {};
-- This inner loop assumes chain is an event-based, not a filter-based
-- chain (filter-based will be added later)
for _, rule in ipairs(rules) do
for _, condition in ipairs(rule.conditions) do
if condition:find("^not%(.+%)$") then
condition = condition:match("^not%((.+)%)$");
end
condition_uses[condition] = (condition_uses[condition] or 0) + 1;
end
end
local condition_cache, n_conditions = {}, 0;
for _, rule in ipairs(rules) do
for _, dep in ipairs(rule.deps) do
include_dep(dep, code);
end
table.insert(code, "\n\t\t");
local rule_code;
if #rule.conditions > 0 then
for i, condition in ipairs(rule.conditions) do
local negated = condition:match("^not%(.+%)$");
if negated then
condition = condition:match("^not%((.+)%)$");
end
if condition_uses[condition] > 1 then
local name = condition_cache[condition];
if not name then
n_conditions = n_conditions + 1;
name = "condition"..n_conditions;
condition_cache[condition] = name;
table.insert(code, "local "..name.." = "..condition..";\n\t\t");
end
rule.conditions[i] = (negated and "not(" or "")..name..(negated and ")" or "");
else
rule.conditions[i] = (negated and "not(" or "(")..condition..")";
end
end
rule_code = "if "..table.concat(rule.conditions, " and ").." then\n\t\t\t"
..table.concat(rule.actions, "\n\t\t\t")
.."\n\t\tend\n";
else
rule_code = table.concat(rule.actions, "\n\t\t");
end
table.insert(code, rule_code);
end
for name in pairs(definition_handlers) do
table.insert(code.global_header, 1, "local "..name:lower().."s = definitions."..name..";");
end
local code_string = "return function (definitions, fire_event, log, module, pass_return)\n\t"
..table.concat(code.global_header, "\n\t")
.."\n\tlocal db = require 'util.debug';\n\n\t"
.."return function (event)\n\t\t"
.."local stanza, session = event.stanza, event.origin;\n"
..table.concat(code, "")
.."\n\tend;\nend";
chain_handlers[chain_name] = code_string;
end
return chain_handlers;
end
local function compile_firewall_rules(filename)
local ruleset, err = parse_firewall_rules(filename);
if not ruleset then return nil, err; end
local chain_handlers = process_firewall_rules(ruleset);
return chain_handlers;
end
-- Compile handler code into a factory that produces a valid event handler. Factory accepts
-- a value to be returned on PASS
local function compile_handler(code_string, filename)
-- Prepare event handler function
local chunk, err = envload(code_string, "="..filename, _G);
if not chunk then
return nil, "Error compiling (probably a compiler bug, please report): "..err;
end
local function fire_event(name, data)
return module:fire_event(name, data);
end
local init_ok, initialized_chunk = pcall(chunk);
if not init_ok then
return nil, "Error initializing compiled rules: "..initialized_chunk;
end
return function (pass_return)
return initialized_chunk(active_definitions, fire_event, logger(filename), module, pass_return); -- Returns event handler with upvalues
end
end
local function resolve_script_path(script_path)
local relative_to = prosody.paths.config;
if script_path:match("^module:") then
relative_to = module.path:sub(1, -#("/mod_"..module.name..".lua"));
script_path = script_path:match("^module:(.+)$");
end
return resolve_relative_path(relative_to, script_path);
end
-- [filename] = { last_modified = ..., events_hooked = { [name] = handler } }
local loaded_scripts = {};
function load_script(script)
script = resolve_script_path(script);
local last_modified = (lfs.attributes(script) or {}).modification or os.time();
if loaded_scripts[script] then
if loaded_scripts[script].last_modified == last_modified then
return; -- Already loaded, and source file hasn't changed
end
module:log("debug", "Reloading %s", script);
-- Already loaded, but the source file has changed
-- unload it now, and we'll load the new version below
unload_script(script, true);
end
local chain_functions, err = compile_firewall_rules(script);
if not chain_functions then
module:log("error", "Error compiling %s: %s", script, err or "unknown error");
return;
end
-- Loop through the chains in the script, and for each chain attach the compiled code to the
-- relevant events, keeping track in events_hooked so we can cleanly unload later
local events_hooked = {};
for chain, handler_code in pairs(chain_functions) do
local new_handler, err = compile_handler(handler_code, "mod_firewall::"..chain);
if not new_handler then
module:log("error", "Compilation error for %s: %s", script, err);
else
local chain_definition = chains[chain];
if chain_definition and chain_definition.type == "event" then
local handler = new_handler(chain_definition.pass_return);
for _, event_name in ipairs(chain_definition) do
events_hooked[event_name] = handler;
module:hook(event_name, handler, chain_definition.priority);
end
elseif not chain:sub(1, 5) == "user/" then
module:log("warn", "Unknown chain %q", chain);
end
local event_name, handler = "firewall/chains/"..chain, new_handler(false);
events_hooked[event_name] = handler;
module:hook(event_name, handler);
end
end
loaded_scripts[script] = { last_modified = last_modified, events_hooked = events_hooked };
module:log("debug", "Loaded %s", script);
end
--COMPAT w/0.9 (no module:unhook()!)
local function module_unhook(event, handler)
return module:unhook_object_event((hosts[module.host] or prosody).events, event, handler);
end
function unload_script(script, is_reload)
script = resolve_script_path(script);
local script_info = loaded_scripts[script];
if not script_info then
return; -- Script not loaded
end
local events_hooked = script_info.events_hooked;
for event_name, event_handler in pairs(events_hooked) do
module_unhook(event_name, event_handler);
events_hooked[event_name] = nil;
end
loaded_scripts[script] = nil;
if not is_reload then
module:log("debug", "Unloaded %s", script);
end
end
-- Given a set of scripts (e.g. from config) figure out which ones need to
-- be loaded, which are already loaded but need unloading, and which to reload
function load_unload_scripts(script_list)
local wanted_scripts = script_list / resolve_script_path;
local currently_loaded = set.new(it.to_array(it.keys(loaded_scripts)));
local scripts_to_unload = currently_loaded - wanted_scripts;
for script in wanted_scripts do
-- If the script is already loaded, this is fine - it will
-- reload the script for us if the file has changed
load_script(script);
end
for script in scripts_to_unload do
unload_script(script);
end
end
function module.load()
if not prosody.arg then return end -- Don't run in prosodyctl
local firewall_scripts = module:get_option_set("firewall_scripts", {});
load_unload_scripts(firewall_scripts);
-- Replace contents of definitions table (shared) with active definitions
for k in it.keys(definitions) do definitions[k] = nil; end
for k,v in pairs(active_definitions) do definitions[k] = v; end
end
function module.save()
return { active_definitions = active_definitions, loaded_scripts = loaded_scripts };
end
function module.restore(state)
active_definitions = state.active_definitions;
loaded_scripts = state.loaded_scripts;
end
module:hook_global("config-reloaded", function ()
load_unload_scripts(module:get_option_set("firewall_scripts", {}));
end);
function module.command(arg)
if not arg[1] or arg[1] == "--help" then
require"util.prosodyctl".show_usage([[mod_firewall <firewall.pfw>]], [[Compile files with firewall rules to Lua code]]);
return 1;
end
local verbose = arg[1] == "-v";
if verbose then table.remove(arg, 1); end
if arg[1] == "test" then
table.remove(arg, 1);
return module:require("test")(arg);
end
local serialize = require "util.serialization".serialize;
if verbose then
print("local logger = require \"util.logger\".init;");
print();
print("local function fire_event(name, data)\n\tmodule:fire_event(name, data)\nend");
print();
end
for _, filename in ipairs(arg) do
filename = resolve_script_path(filename);
print("do -- File "..filename);
local chain_functions = assert(compile_firewall_rules(filename));
if verbose then
print();
print("local active_definitions = "..serialize(active_definitions)..";");
print();
end
local c = 0;
for chain, handler_code in pairs(chain_functions) do
c = c + 1;
print("---- Chain "..chain:gsub("_", " "));
local chain_func_name = "chain_"..tostring(c).."_"..chain:gsub("%p", "_");
if not verbose then
print(("%s = %s;"):format(chain_func_name, handler_code:sub(8)));
else
print(("local %s = (%s)(active_definitions, fire_event, logger(%q));"):format(chain_func_name, handler_code:sub(8), filename));
print();
local chain_definition = chains[chain];
if chain_definition and chain_definition.type == "event" then
for _, event_name in ipairs(chain_definition) do
print(("module:hook(%q, %s, %d);"):format(event_name, chain_func_name, chain_definition.priority or 0));
end
end
print(("module:hook(%q, %s, %d);"):format("firewall/chains/"..chain, chain_func_name, chain_definition.priority or 0));
end
print("---- End of chain "..chain);
print();
end
print("end -- End of file "..filename);
end
end
-- Console
local console_env = module:shared("/*/admin_shell/env");
console_env.firewall = {};
function console_env.firewall:mark(user_jid, mark_name)
local username, host = jid.split(user_jid);
if not username or not hosts[host] then
return nil, "Invalid JID supplied";
elseif not idsafe(mark_name) then
return nil, "Invalid characters in mark name";
end
if not module:context(host):fire_event("firewall/marked/user", {
username = session.username;
mark = mark_name;
timestamp = os.time();
}) then
return nil, "Mark not set - is mod_firewall loaded on that host?";
end
return true, "User marked";
end
function console_env.firewall:unmark(jid, mark_name)
local username, host = jid.split(user_jid);
if not username or not hosts[host] then
return nil, "Invalid JID supplied";
elseif not idsafe(mark_name) then
return nil, "Invalid characters in mark name";
end
if not module:context(host):fire_event("firewall/unmarked/user", {
username = session.username;
mark = mark_name;
}) then
return nil, "Mark not removed - is mod_firewall loaded on that host?";
end
return true, "User unmarked";
end

View File

@@ -0,0 +1,75 @@
-- luacheck: globals load_unload_scripts
local set = require "util.set";
local ltn12 = require "ltn12";
local xmppstream = require "util.xmppstream";
local function stderr(...)
io.stderr:write("** ", table.concat({...}, "\t", 1, select("#", ...)), "\n");
end
return function (arg)
require "net.http".request = function (url, ex, cb)
stderr("Making HTTP request to "..url);
local body_table = {};
local ok, response_status, response_headers = require "ssl.https".request({
url = url;
headers = ex.headers;
method = ex.body and "POST" or "GET";
sink = ltn12.sink.table(body_table);
source = ex.body and ltn12.source.string(ex.body) or nil;
});
stderr("HTTP response "..response_status);
cb(table.concat(body_table), response_status, { headers = response_headers });
return true;
end;
local stats_dropped, stats_passed = 0, 0;
load_unload_scripts(set.new(arg));
local stream_callbacks = { default_ns = "jabber:client" };
function stream_callbacks.streamopened(session)
session.notopen = nil;
end
function stream_callbacks.streamclosed()
end
function stream_callbacks.error(session, error_name, error_message) -- luacheck: ignore 212/session
stderr("Fatal error parsing XML stream: "..error_name..": "..tostring(error_message))
assert(false);
end
function stream_callbacks.handlestanza(session, stanza)
if not module:fire_event("firewall/chains/deliver", { origin = session, stanza = stanza }) then
stats_passed = stats_passed + 1;
print(stanza);
print("");
else
stats_dropped = stats_dropped + 1;
end
end
local session = { notopen = true };
function session.send(stanza)
stderr("Reply:", "\n"..tostring(stanza).."\n");
end
local stream = xmppstream.new(session, stream_callbacks);
stream:feed("<stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client'>");
local line_count = 0;
for line in io.lines() do
line_count = line_count + 1;
local ok, err = stream:feed(line.."\n");
if not ok then
stderr("Fatal XML parse error on line "..line_count..": "..err);
return 1;
end
end
stderr("Summary");
stderr("-------");
stderr("");
stderr(stats_dropped + stats_passed, "processed");
stderr(stats_passed, "passed");
stderr(stats_dropped, "dropped");
stderr(line_count, "input lines");
stderr("");
end

View File

@@ -16,6 +16,7 @@ local new_id = require 'util.id'.medium;
local filters = require 'util.filters';
local util = module:require 'util';
local is_vpaas = util.is_vpaas;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local get_room_from_jid = util.get_room_from_jid;
local get_focus_occupant = util.get_focus_occupant;
@@ -48,6 +49,8 @@ local measure_visitors = module:measure('vnode-visitors', 'amount');
local sent_iq_cache = require 'util.cache'.new(200);
local sessions = prosody.full_sessions;
local um_is_admin = require 'core.usermanager'.is_admin;
local function is_admin(jid)
return um_is_admin(jid, module.host);
@@ -185,6 +188,25 @@ module:hook('muc-broadcast-presence', function (event)
local raiseHand = full_p:get_child_text('jitsi_participant_raisedHand');
-- a promotion detected let's send it to main prosody
if raiseHand then
local user_id;
local is_moderator;
local session = sessions[occupant.jid];
local identity = session and session.jitsi_meet_context_user;
if is_vpaas(room.jid) and identity then
-- in case of moderator in vpass meeting we want to do auto-promotion
local is_vpaas_moderator = identity.moderator;
if is_vpaas_moderator == 'true' or is_vpaas_moderator == true then
is_moderator = true;
end
else
-- The case with single moderator in the room, we want to report our id
-- so we can be auto promoted
if identity and identity.id then
user_id = session.jitsi_meet_context_user.id;
end
end
local iq_id = new_id();
sent_iq_cache:set(iq_id, socket.gettime());
local promotion_request = st.iq({
@@ -194,7 +216,13 @@ module:hook('muc-broadcast-presence', function (event)
id = iq_id })
:tag('visitors', { xmlns = 'jitsi:visitors',
room = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain) })
:tag('promotion-request', { xmlns = 'jitsi:visitors', jid = occupant.jid }):up();
:tag('promotion-request', {
xmlns = 'jitsi:visitors',
jid = occupant.jid,
time = raiseHand,
userId = user_id,
forcePromote = is_moderator and 'true' or 'false';
}):up();
local nick_element = occupant:get_presence():get_child('nick', NICK_NS);
if nick_element then
@@ -440,6 +468,8 @@ local function iq_from_main_handler(event)
}));
if process_disconnect then
cancel_destroy_timer(room);
local main_count, visitors_count = get_occupant_counts(room);
module:log('info', 'Will destroy:%s main_occupants:%s visitors:%s', room.jid, main_count, visitors_count);
room:destroy(nil, 'Conference ended.');

View File

@@ -0,0 +1,120 @@
module:set_global();
local loggingmanager = require "core.loggingmanager";
local format = require "util.format".format;
local pposix = require "util.pposix";
local rb = require "util.ringbuffer";
local queue = require "util.queue";
local default_timestamp = "%b %d %H:%M:%S ";
local max_chunk_size = module:get_option_number("log_ringbuffer_chunk_size", 16384);
local os_date = os.date;
local default_filename_template = "{paths.data}/ringbuffer-logs-{pid}-{count}.log";
local render_filename = require "util.interpolation".new("%b{}", function (s) return s; end, {
yyyymmdd = function (t)
return os_date("%Y%m%d", t);
end;
hhmmss = function (t)
return os_date("%H%M%S", t);
end;
});
local dump_count = 0;
local function dump_buffer(dump, filename)
dump_count = dump_count + 1;
local f, err = io.open(filename, "a+");
if not f then
module:log("error", "Unable to open output file: %s", err);
return;
end
f:write(("-- Dumping log buffer at %s --\n"):format(os_date(default_timestamp)));
dump(f);
f:write("-- End of dump --\n\n");
f:close();
end
local function get_filename(filename_template)
filename_template = filename_template or default_filename_template;
return render_filename(filename_template, {
paths = prosody.paths;
pid = pposix.getpid();
count = dump_count;
time = os.time();
});
end
local function new_buffer(config)
local write, dump;
if config.lines then
local buffer = queue.new(config.lines, true);
function write(line)
buffer:push(line);
end
function dump(f)
-- COMPAT w/0.11 - update to use :consume()
for line in buffer.pop, buffer do
f:write(line);
end
end
else
local buffer_size = config.size or 100*1024;
local buffer = rb.new(buffer_size);
function write(line)
if not buffer:write(line) then
if #line > buffer_size then
buffer:discard(buffer_size);
buffer:write(line:sub(-buffer_size));
else
buffer:discard(#line);
buffer:write(line);
end
end
end
function dump(f)
local bytes_remaining = buffer:length();
while bytes_remaining > 0 do
local chunk_size = math.min(bytes_remaining, max_chunk_size);
local chunk = buffer:read(chunk_size);
if not chunk then
return;
end
f:write(chunk);
bytes_remaining = bytes_remaining - chunk_size;
end
end
end
return write, dump;
end
local function ringbuffer_log_sink_maker(sink_config)
local write, dump = new_buffer(sink_config);
local timestamps = sink_config.timestamps;
if timestamps == true or timestamps == nil then
timestamps = default_timestamp; -- Default format
elseif timestamps then
timestamps = timestamps .. " ";
end
local function handler()
dump_buffer(dump, sink_config.filename or get_filename(sink_config.filename_template));
end
if sink_config.signal then
require "util.signal".signal(sink_config.signal, handler);
elseif sink_config.event then
module:hook_global(sink_config.event, handler);
end
return function (name, level, message, ...)
local line = format("%s%s\t%s\t%s\n", timestamps and os_date(timestamps) or "", name, level, format(message, ...));
write(line);
end;
end
loggingmanager.register_sink_type("ringbuffer", ringbuffer_log_sink_maker);

View File

@@ -0,0 +1,32 @@
module:set_global()
local filters = require"util.filters";
local stanzas_in = module:metric(
"counter", "received", "",
"Stanzas received by Prosody",
{ "session_type", "stanza_kind" }
)
local stanzas_out = module:metric(
"counter", "sent", "",
"Stanzas sent by prosody",
{ "session_type", "stanza_kind" }
)
local stanza_kinds = { message = true, presence = true, iq = true };
local function rate(metric_family)
return function (stanza, session)
if stanza.attr and not stanza.attr.xmlns and stanza_kinds[stanza.name] then
metric_family:with_labels(session.type, stanza.name):add(1);
end
return stanza;
end
end
local function measure_stanza_counts(session)
filters.add_filter(session, "stanzas/in", rate(stanzas_in));
filters.add_filter(session, "stanzas/out", rate(stanzas_out));
end
filters.add_filter_hook(measure_stanza_counts);

View File

@@ -37,6 +37,8 @@ local tostring = tostring;
-- required parameter for custom muc component prefix, defaults to "conference"
local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference");
local leaked_rooms;
--- handles request to get number of participants in all rooms
-- @return GET response
function handle_get_room_census(event)
@@ -46,18 +48,37 @@ function handle_get_room_census(event)
end
room_data = {}
leaked_rooms = 0;
for room in host_session.modules.muc.each_room() do
if not is_healthcheck_room(room.jid) then
local occupants = room._occupants;
local participant_count = 0;
local missing_connections_count = 0;
if occupants then
participant_count = iterators.count(room:each_occupant()) - 1; -- subtract focus
else
participant_count = 0
for _, o in room:each_occupant() do
participant_count = participant_count + 1;
-- let's check whether that occupant has connection in the full_sessions of prosody
-- attempt to detect leaked occupants/rooms.
if prosody.full_sessions[o.jid] == nil then
missing_connections_count = missing_connections_count + 1;
end
end
participant_count = participant_count - 1; -- subtract focus
end
local leaked = false;
if participant_count > 0 and missing_connections_count == participant_count then
leaked = true;
leaked_rooms = leaked_rooms + 1;
end
table.insert(room_data, {
room_name = room.jid;
participants = participant_count;
created_time = room.created_timestamp;
leaked = leaked;
});
end
end
@@ -77,3 +98,9 @@ function module.load()
};
});
end
-- we calculate the stats on the configured interval (60 seconds by default)
local measure_leaked_rooms = module:measure('leaked_rooms', 'amount');
module:hook_global('stats-update', function ()
measure_leaked_rooms(leaked_rooms);
end);

View File

@@ -3,12 +3,16 @@ module:log('info', 'Starting visitors_component at %s', module.host);
local jid = require 'util.jid';
local st = require 'util.stanza';
local util = module:require 'util';
local is_healthcheck_room = util.is_healthcheck_room;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local get_room_from_jid = util.get_room_from_jid;
local get_focus_occupant = util.get_focus_occupant;
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local is_vpaas = util.is_vpaas;
local new_id = require 'util.id'.medium;
local um_is_admin = require 'core.usermanager'.is_admin;
local json = require 'util.json';
local MUC_NS = 'http://jabber.org/protocol/muc';
@@ -32,7 +36,13 @@ end
-- automatically allowed or allowed by a moderator
local visitors_promotion_map = {};
local sent_iq_cache = require 'util.cache'.new(200);
-- A map with key room jid. The content is a map with key jid from which the request is received
-- and the value is a table that has the json message that needs to be sent to any future moderator that joins
-- and the vnode from which the request is received and where the response will be sent
local visitors_promotion_requests = {};
local cache = require 'util.cache';
local sent_iq_cache = cache.new(200);
-- send iq result that the iq was received and will be processed
local function respond_iq_result(origin, stanza)
@@ -45,15 +55,23 @@ local function respond_iq_result(origin, stanza)
}));
end
local function request_promotion_received(room, from_jid, from_vnode)
if not visitors_promotion_map[room.jid] and auto_allow_promotion then
-- visitors is enabled
visitors_promotion_map[room.jid] = {};
end
-- Sends a json-message to the destination jid
-- @param to_jid the destination jid
-- @param json_message the message content to send
function send_json_message(to_jid, json_message)
local stanza = st.message({ from = module.host; to = to_jid; })
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(json_message):up();
module:send(stanza);
end
local function request_promotion_received(room, from_jid, from_vnode, nick, time, user_id, force_promote)
-- if visitors is enabled for the room
if visitors_promotion_map[room.jid] then
if auto_allow_promotion then
-- only for raise hand, ignore lowering the hand
if time and time > 0 and (
auto_allow_promotion
or (user_id and user_id == room._data.moderator_id)
or force_promote == 'true') then
-- we are in auto-allow mode, let's reply with accept
-- we store where the request is coming from so we can send back the response
local username = new_id():lower();
@@ -82,20 +100,72 @@ local function request_promotion_received(room, from_jid, from_vnode)
username = username ,
allow = 'true' }):up());
return true;
end
else
-- send promotion request to all moderators
local body_json = {};
body_json.type = 'visitors';
body_json.room = internal_room_jid_match_rewrite(room.jid);
body_json.action = 'promotion-request';
body_json.nick = nick;
body_json.from = from_jid;
-- TODO send promotion request to all moderators
module:log('warn', 'Received promotion request from %s for room %s without active visitors', from, room.jid);
return;
if time and time > 0 then
-- raise hand
body_json.on = true;
else
-- lower hand, we want to inform interested parties that
-- the visitor is no longer interested in joining the main call
body_json.on = false;
end
local msg_to_send = json.encode(body_json);
if visitors_promotion_requests[room.jid] then
visitors_promotion_requests[room.jid][from_jid] = {
msg = msg_to_send;
from = from_vnode;
};
else
module:log('warn', 'Received promotion request for room %s with visitors not enabled. %s',
room.jid, msg_to_send);
end
-- let's send a notification to every moderator
for _, occupant in room:each_occupant() do
if occupant.role == 'moderator' then
send_json_message(occupant.jid, msg_to_send);
end
end
return true;
end
end
module:log('warn', 'Received promotion request from %s for room %s without active visitors', from, room.jid);
end
local function connect_vnode_received(room, vnode)
module:context(muc_domain_base):fire_event('jitsi-connect-vnode', { room = room; vnode = vnode; });
if not visitors_promotion_map[room.jid] then
-- visitors is enabled
visitors_promotion_map[room.jid] = {};
visitors_promotion_requests[room.jid] = {};
room._connected_vnodes = cache.new(16); -- we up to 16 vnodes for this prosody
end
room._connected_vnodes:set(vnode..'.meet.jitsi', 'connected');
end
local function disconnect_vnode_received(room, vnode)
module:context(muc_domain_base):fire_event('jitsi-disconnect-vnode', { room = room; vnode = vnode; });
room._connected_vnodes:set(vnode..'.meet.jitsi', nil);
if room._connected_vnodes:count() == 0 then
visitors_promotion_map[room.jid] = nil;
visitors_promotion_requests[room.jid] = nil;
room._connected_vnodes = nil;
end
end
-- listens for iq request for promotion and forward it to moderators in the meeting for approval
@@ -140,7 +210,28 @@ local function stanza_handler(event)
-- promotion request is coming from visitors and is a set and is over the s2s connection
local request_promotion = visitors_iq:get_child('promotion-request');
if request_promotion then
processed = request_promotion_received(room, request_promotion.attr.jid, stanza.attr.from);
if not (room._connected_vnodes and room._connected_vnodes:get(stanza.attr.from)) then
module:log('warn', 'Received forged promotion-request: %s %s %s', stanza, inspect(room._connected_vnodes), room._connected_vnodes:get(stanza.attr.from));
return true; -- stop processing
end
local force_promote = request_promotion.attr.forcePromote;
if force_promote == 'true' and not is_vpaas(room.jid) then
module:log('warn', 'Received promotion request for non vpass room (%s) with forced promotion: ',
room.jid, stanza);
return true; -- stop processing
end
local display_name = visitors_iq:get_child_text('nick', 'http://jabber.org/protocol/nick');
processed = request_promotion_received(
room,
request_promotion.attr.jid,
stanza.attr.from,
display_name,
tonumber(request_promotion.attr.time),
request_promotion.attr.userId,
force_promote
);
end
-- connect and disconnect are only received from jicofo
@@ -218,24 +309,125 @@ process_host_module(muc_domain_prefix..'.'..muc_domain_base, function(host_modul
end, 7); -- after muc_meeting_id, the logic for not joining before jicofo
host_module:hook('muc-room-destroyed', function (event)
visitors_promotion_map[event.room.jid] = nil;
visitors_promotion_requests[event.room.jid] = nil;
end);
host_module:hook('muc-occupant-joined', function (event)
local room, occupant = event.room, event.occupant;
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) or occupant.role ~= 'moderator' then -- luacheck: ignore
return;
end
for _,value in pairs(visitors_promotion_requests[event.room.jid]) do
send_json_message(occupant.jid, value.msg);
end
end);
host_module:hook('muc-set-affiliation', function (event)
-- the actor can be nil if is coming from allowners or similar module we want to skip it here
-- as we will handle it in occupant_joined
local actor, affiliation, jid, room = event.actor, event.affiliation, event.jid, event.room;
if is_admin(jid) or is_healthcheck_room(room.jid) or not actor or not affiliation == 'owner' -- luacheck: ignore
or not visitors_promotion_requests[event.room.jid] then
return;
end
-- event.jid is the bare jid of participant
for _, occupant in room:each_occupant() do
if occupant.bare_jid == event.jid then
for _,value in pairs(visitors_promotion_requests[event.room.jid]) do
send_json_message(occupant.jid, value.msg);
end
end
end
end);
host_module:hook("message/bare", function(event)
local stanza = event.stanza;
if stanza.attr.type ~= "groupchat" then
return;
end
local json_data = stanza:get_child_text("json-message", "http://jitsi.org/jitmeet");
if json_data == nil then
return;
end
local data = json.decode(json_data);
if not data or data.type ~= 'visitors' or data.action ~= "promotion-response" then
return;
end
local room = get_room_from_jid(event.stanza.attr.to);
local occupant_jid = event.stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(occupant_jid);
if not occupant then
module:log("error", "Occupant %s was not found in room %s", occupant_jid, room.jid)
return
end
if occupant.role ~= 'moderator' then
module:log('error', 'Occupant %s sending response message but not moderator in room %s',
occupant_jid, room.jid);
return false;
end
-- let's forward to every moderator, this is so they now that this moderator
-- took action and they can update UI, as this msg was initially a group chat but we are
-- sending it now as provide chat, let's change the type
stanza.attr.type = 'chat'; -- it is safe as we are not using this stanza instance anymore
for _, room_occupant in room:each_occupant() do
-- if moderator send the message
if room_occupant.role == 'moderator'
and room_occupant.jid ~= occupant.jid
and not is_admin(room_occupant.bare_jid) then
stanza.attr.to = room_occupant.nick;
room:route_stanza(stanza);
end
end
-- lets reply to participant that requested promotion
local username = new_id():lower();
visitors_promotion_map[room.jid][username] = {
from = visitors_promotion_requests[room.jid][data.id].from;
jid = data.id;
};
local req_from = visitors_promotion_map[room.jid][username].from;
local req_jid = visitors_promotion_map[room.jid][username].jid;
local focus_occupant = get_focus_occupant(room);
local focus_jid = focus_occupant and focus_occupant.bare_jid or nil;
local iq_id = new_id();
sent_iq_cache:set(iq_id, socket.gettime());
module:send(st.iq({
type='set', to = req_from, from = module.host, id = iq_id })
:tag('visitors', {
xmlns='jitsi:visitors',
room = string.gsub(room.jid, muc_domain_base, req_from),
focusjid = focus_jid })
:tag('promotion-response', {
xmlns='jitsi:visitors',
jid = req_jid,
username = username ,
allow = data.approved and 'true' or 'false' }):up());
return false; -- halt processing
end);
end);
-- enable only in case of auto-allow is enabled
if auto_allow_promotion then
prosody.events.add_handler('pre-jitsi-authentication', function(session)
if not session.customusername or not session.jitsi_web_query_room then
return nil;
end
prosody.events.add_handler('pre-jitsi-authentication', function(session)
if not session.customusername or not session.jitsi_web_query_room then
return nil;
end
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
return nil;
end
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
return nil;
end
if visitors_promotion_map[room.jid] and visitors_promotion_map[room.jid][session.customusername] then
-- user was previously allowed to join, let him use the requested jid
return session.customusername;
end
end);
end
if visitors_promotion_map[room.jid] and visitors_promotion_map[room.jid][session.customusername] then
-- user was previously allowed to join, let him use the requested jid
return session.customusername;
end
end);

View File

@@ -416,11 +416,33 @@ function is_moderated(room_jid)
return false;
end
-- check if the room tenant starts with
-- vpaas-magic-cookie-
function is_vpaas(room_jid)
local node, host = jid.split(room_jid);
if host ~= muc_domain or not node then
module:log('debug', 'Not the same host');
return false;
end
local tenant, conference_name = node:match('^%[([^%]]+)%](.+)$');
if not (tenant and conference_name) then
module:log('debug', 'Not a vpaas room %s', room_jid);
return false;
end
local vpaas_prefix, _ = tenant:match('^(vpaas%-magic%-cookie%-)(.*)$')
if vpaas_prefix ~= 'vpaas-magic-cookie-' then
module:log('debug', 'Not a vpaas room %s', room_jid);
return false
end
return true
end
return {
extract_subdomain = extract_subdomain;
is_feature_allowed = is_feature_allowed;
is_healthcheck_room = is_healthcheck_room;
is_moderated = is_moderated;
is_vpaas = is_vpaas;
get_focus_occupant = get_focus_occupant;
get_room_from_jid = get_room_from_jid;
get_room_by_name_and_subdomain = get_room_by_name_and_subdomain;