Compare commits

...

114 Commits

Author SHA1 Message Date
damencho
a24ddf205c feat(visitors): Adds enabled flag in the metadata. 2025-07-09 15:51:21 +03:00
damencho
7a97d15e89 feat(conference): Clears any error from previous attempts.
When you see the error, you may click join on pre-join again, which may succeeded, so clear previous errors.
2025-07-09 14:14:49 +03:00
damencho
1acb99d763 fix(av-moderation): Fixes auto starting av moderation, notify everyone. 2025-07-08 21:18:44 +03:00
damencho
adbe990867 fix(visitors): A join case with live rooms. 2025-07-08 19:10:28 +03:00
Saúl Ibarra Corretgé
a4367567ab fix(amplitude) adjust to new SDK API changes
Ref: https://amplitude.com/docs/sdks/analytics/browser/migrate-from-javascript-sdk-to-browser-sdk-2-0
2025-07-08 17:40:46 +02:00
damencho
7f56cbc4ce fix(av-moderation): Fixes auto starting av moderation.
There are some startMuted policies we set when starting it.
2025-07-08 16:18:57 +03:00
damencho
d636d084c8 fix(visitors): Fixes empty array case and wrong json.
When there is empty array cjson produces array: {} while prosody's json impl checks is it array and produces the correct value (array: []). Prosody impl is a little bit slower, but this is not a hot path and those are not huge json strings.
2025-07-08 13:31:05 +03:00
damencho
298567be48 fix(visitors): Updates docs, drops s2soutinjection.
That module was initially dropped with 354a3c002a.
2025-07-08 10:25:23 +03:00
Boris Grozev
c233629e51 fix: Do not merge participants and moderators into room metadata. 2025-07-08 06:00:35 +03:00
Saúl Ibarra Corretgé
75b5702a7e fix(file-sharing) fix resetting the state for share file input
Otherwise re-uploading the same file would not work because the input
element doesn't change state, as the value would remain the same.
2025-07-07 15:36:09 +02:00
benasm7
540f01d47e fix(virtual-background): Fix i18n for a device error.
* Reusing existing translation string for virtual background error notification, instead of current hardcoded english value.

* Update VirtualBackgroundPreview.tsx
2025-07-07 07:57:48 -05:00
Robert Oanta
5c7ed6a8b3 feat(av_moderation): handle av_can_unmute policy 2025-07-07 15:33:28 +03:00
damencho
3c5d33fefa fix(visitors): Avoid go live to overwrite other settings. 2025-07-07 15:33:14 +03:00
ltorje-8x8
be04236834 feat(visitors): Fixes nil error about 'get_visitors_room_metadata'
* Attempt to call a nil value (global 'get_visitors_room_metadata')

* make the linter happy

* more trailing whitespace + cleanup

* apply review

* use default false
2025-07-07 05:31:13 -05:00
Saúl Ibarra Corretgé
ec1bfe73b3 fix(amplitude) sync device ID on web too
Note the use of jitsiLocalStorage since we also need to consider the
case when local storage is performed in the host page when in an
iframe.
2025-07-07 11:39:04 +02:00
Saúl Ibarra Corretgé
d2ed9ffef6 fix(transcribing) fix overriding transcribing state
Skip updating the transcribing state when the 'audio-recording-enabled'
property is not provided.

This fixes a race when a transcriber is already in the room, we'll see
it before properties are updated (sometimes) and without checking for
undefined we'd flip the local value to false.
2025-07-04 17:15:06 +02:00
Saúl Ibarra Corretgé
6141ff78f8 fix(rn,embed) remove 8x8 apps from isEmbedded check
For all intents and purposes 8x8 apps are integrating the SDK as a 3rd
party.

Yes, we are a 1st party of sorts, but that's ok because 8x8.vc allows
embedding.
2025-07-04 15:31:14 +02:00
Saúl Ibarra Corretgé
c6a75fb9ed fix(file-sharing) hide upload button for visitors 2025-07-04 13:19:26 +02:00
Andrei Gavrilescu
3438438219 feat(recording): enable consent dialog on spot (#16179)
* enable consent dialog on spot

* lint fix

* move spot consent behind config flag

* revert copilot magic
2025-07-04 11:45:01 +03:00
Matteo
7cedea6740 lang: update Italian translation 2025-07-04 10:37:49 +02:00
Hristo Terezov
69f26c8a38 fix(participant-pane): Don't show the Viewers label twice. 2025-07-03 19:00:38 -05:00
Hristo Terezov
92a4750d0e fix(VisitorsList): use separate stomp instance. 2025-07-03 19:00:38 -05:00
Hristo Terezov
370a884765 fix(visitors): avoid lost deltas when subscribing 2025-07-03 19:00:38 -05:00
Hristo Terezov
877fc98eef feat(visitors-list): Add to participant pane. 2025-07-03 07:52:09 -05:00
Jaya Allamsetty
7bed0b36bd chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2024.0.0+006b25e4...v2025.0.0+49eb29a8
2025-07-02 22:30:40 -04:00
damencho
cd5aed37e9 feat(filesharing): Adds a nil check.
In case of file failing to upload we try to remove it, but there was nothing indicated as added before that.
2025-07-02 15:01:01 -05:00
damencho
b8dad082df chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2021.0.0+5a044f1a...v2024.0.0+006b25e4
2025-07-02 14:20:22 -05:00
damencho
f84f98e8e5 fix(visitors): Allow joining queue when not prefer to be visitor. 2025-07-02 14:20:22 -05:00
damencho
d1328d68f2 fix(visitors): Deny access when room is not live and there is a list of participants. 2025-07-02 14:20:22 -05:00
damencho
43d5c1e3ba feat(visitors): Adds allow promotion setting per room. 2025-07-02 14:20:22 -05:00
damencho
22ed00724d fix(visitors): Checks mainMeetingParticipants array to allow joins.
squash: Change checks in find table.
2025-07-02 14:20:22 -05:00
Horatiu Muresan
0b095f36eb fix(file-sharing) Keep original filename on file download (#16183) 2025-07-02 16:49:42 +03:00
Jaya Allamsetty
327376d85e chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2018.0.0+1773bcff...v2021.0.0+5a044f1a
2025-07-01 13:02:28 -04:00
Saúl Ibarra Corretgé
f28bd67ff4 fix(PressureObserver) adapt to API changes
Also set a sampling intervakl of 30s to avoid too chatty logs.
2025-07-01 16:47:15 +02:00
Horatiu Muresan
3a54c3418b feat(filmstrip) Add always visible resize bar and initial width (#16181) 2025-07-01 16:07:47 +03:00
TTG
b6026dcd04 fix(lang) Update zhCN & zhTW translations and fix English apostrophes (#16174)
* Update main-zhTW.json

* Update main-zhCN.json

* Update main.json
2025-06-30 03:42:52 -05:00
Jaya Allamsetty
2eff0d8f78 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2012.0.0+86b76227...v2018.0.0+1773bcff
2025-06-27 13:39:38 -04:00
Saúl Ibarra Corretgé
1ab7989a1a fix(file-sharing) fix computing file sharing percentage
Actually implement what the comment says: we want to cap the progress at
99% so we wait for the request to complete with 200 before going to
100%.
2025-06-27 16:34:10 +02:00
Calinteodor
b8c6c2381c dep(@amplitude): replace with web and native deps with new ones (#16169)
Replaced Amplitude related dependencies with https://github.com/amplitude/Amplitude-TypeScript
2025-06-27 15:39:33 +03:00
Avram Tudor
3f9202ce04 fix: hide chat controls and show disabled notice instead (#16168) 2025-06-26 17:49:48 +03:00
Avram Tudor
965b413d26 fix: hide create poll button rather than disabling it (#16167) 2025-06-26 15:32:29 +03:00
Avram Tudor
1cb2025951 fix: layout issue when only chat tab is visible (#16166) 2025-06-26 14:44:09 +03:00
Avram Tudor
4decb41a1e feat: allow private chats between users and moderators (#16165)
* fix context menu of a chat bubble containing the private message entry when private chat should be disabled
2025-06-26 14:43:54 +03:00
Saúl Ibarra Corretgé
8a79d200c8 fix(android) disable HW AV1 codec
We have observed terrible framerates due to the decoder getting
constantly restarted. Surprisingly, this only happens when using the
JVB, it does not happen when using P2P with Chromium as the other
endpoint.

We shall look into that from the bridge side.

At any rate, I'm disabling the HW video decoder for AV1, which means we
will be using dav1d, the software video decoder.
2025-06-26 13:29:54 +02:00
damencho
2f9436afb1 fix(tests): Skip url normalisation test when using tokens. 2025-06-25 08:18:35 -05:00
damencho
66dc158c22 feat(tests): Adds a debug print for document ready. 2025-06-25 08:18:35 -05:00
damencho
921ed99676 fix(permissions): Fixes grant owner for participant with token. 2025-06-24 12:49:40 -05:00
damencho
bd612ef8ea fix(tests): Fixes Firefox tests excludes. 2025-06-24 08:29:10 -05:00
damencho
b93c69c24e feat(tests): Adds url normalize test. 2025-06-24 08:29:10 -05:00
dependabot[bot]
cc6326a23f chore(deps): bump dompurify, @giphy/js-fetch-api and @giphy/react-components
Removes [dompurify](https://github.com/cure53/DOMPurify). It's no longer used after updating ancestor dependencies [dompurify](https://github.com/cure53/DOMPurify), [@giphy/js-fetch-api](https://github.com/Giphy/giphy-js) and [@giphy/react-components](https://github.com/Giphy/giphy-js). These dependencies need to be updated together.


Removes `dompurify`

Updates `@giphy/js-fetch-api` from 4.7.1 to 4.9.3
- [Release notes](https://github.com/Giphy/giphy-js/releases)
- [Commits](https://github.com/Giphy/giphy-js/compare/@giphy/js-fetch-api@4.7.1...@giphy/js-fetch-api@4.9.3)

Updates `@giphy/react-components` from 6.8.1 to 6.9.4
- [Release notes](https://github.com/Giphy/giphy-js/releases)
- [Commits](https://github.com/Giphy/giphy-js/compare/@giphy/react-components@6.8.1...@giphy/react-components@6.9.4)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-type: indirect
- dependency-name: "@giphy/js-fetch-api"
  dependency-type: direct:production
- dependency-name: "@giphy/react-components"
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-21 10:28:06 +02:00
Saúl Ibarra Corretgé
d6776f234d Revert "feat(android) drop support for x86 architecture"
This reverts commit 2763c4fdee.
2025-06-20 14:01:08 +03:00
Saúl Ibarra Corretgé
2763c4fdee feat(android) drop support for x86 architecture
It's only used by really old Chromebooks, and we provide a TWA for those
anyway.
2025-06-19 17:22:24 +03:00
Calin-Teodor
2aaf0ed543 feat(base/config): remove customToolbarButtons warning 2025-06-19 13:05:34 +03:00
Saúl Ibarra Corretgé
17f335f0c9 fix(ios) remove unused entitlement 2025-06-18 10:43:43 +02:00
damencho
e280d1d963 feat(transcribing): Use invite jigasi option only when async transcriptions are enabled. 2025-06-17 15:51:52 -05:00
damencho
a43472985b chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2006.0.0+04c7ab17...v2012.0.0+86b76227
2025-06-17 15:51:41 -05:00
damencho
e5187de9c3 feat(file-sharing): Uses a filesharing management from ljm.
squash: Change the way we pass the list of files from the backend.

squash: Remove actions to sync local state with remote.
2025-06-17 15:51:41 -05:00
damencho
b296776ed7 feat(prosody): Fixes log messages. 2025-06-17 15:51:41 -05:00
damencho
93bc4019ad feat(prosody): Simplifies modules that need to add identity. 2025-06-17 15:51:41 -05:00
Andrei Gavrilescu
a4c20469cd feat(API): expose recording consent to external api (#16141)
* expose recording consent to api

* Update react/features/recording/actions.web.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-17 14:44:00 +03:00
Saúl Ibarra Corretgé
196fd455cd fix(rn,dynamic-branding) don't override payload
Otherwise we'll miss any new properties. We just keep the sanitization
of avatarBackgrounds, which mobile needs.
2025-06-17 12:30:54 +03:00
Calin-Teodor
e75bacae4a feat(toolbox): use the same name for screen share 2025-06-17 09:16:26 +03:00
damencho
d238386085 feat(polls): Make sure we check for json messages with no namespace.
Missing namespace can interfere with polls logic.
2025-06-16 12:00:01 -05:00
damencho
a1634eb813 feat(iframeAPI): Fires transcribing events when subtitles are on. 2025-06-16 11:25:56 -05:00
Saúl Ibarra Corretgé
566d76a28d fix(android) drop old JSC dependency
We only use Hermes now.
2025-06-16 14:43:56 +03:00
Дамян Минков
494afde82a fix(config): Moves a property to dynamic branding. (#16138)
* fix(config): Moves a property to dynamic branding.

* squash: Remove unused.
2025-06-13 11:24:27 -05:00
Jaya Allamsetty
51ba5d31aa fix(tracks) Log when the MediaStream becomes 'inactive' 2025-06-12 13:57:54 -04:00
Дамян Минков
5f88b117ae fix(visitors): Fixes visitors count. (#16134)
* fix(visitors): Fixes visitors count.

* squash: Simplify logic with new function counting participants to display.
2025-06-11 12:51:33 -05:00
damencho
4500a5aba5 fix(conference): Handles promise rejection on conference failed.
For example when lobby is enabled.
2025-06-10 16:38:57 -05:00
damencho
fb64d1b68b feat(permissions): Clear up granted-permissions.
We do not need to keep granted permissions in separate field. We can always check the granted user-id or whether the current participant has a token (session.auth_token).
2025-06-10 12:20:56 -05:00
Wilson Furtado
a39d8d35a2 feat(base/avatar): Update the avatar to show the first and last name letters rather than first and second name (#15732)
* Update the avatar to select first and last name rather than first and second
2025-06-10 16:47:48 +03:00
Calinteodor
6bc12766f9 feat(react-native-sdk): fixed missing dependencies (#16102)
* feat(react-native-sdk): fixed missing dependencies errors
2025-06-10 13:37:08 +03:00
Mihaela Dumitru
ecf9bee7d0 fix(recordings) create missing local tracks when unmuting after consent (#16119)
* fix(recordings) create missing local tracks when unmuting after consent

* fix(conference) Avoid creating duplicate tracks on unmute

* squash: Ignore TS linter error

---------

Co-authored-by: Jaya Allamsetty <jaya.allamsetty@8x8.com>
2025-06-09 16:44:24 -04:00
Aaron van Meerten
6b5245be44 feat(webpack): rewrite CDN urls when proxying with webpack (#15938)
* feat(webpack): rewrite CDN urls when proxying with webpack

* fix lint

* squash: fix checking for existing file.

---------

Co-authored-by: Дамян Минков <damencho@jitsi.org>
2025-06-09 13:44:57 -05:00
damencho
6b71122cac feat(file-sharing): Update wrong fields.
Suppress a warning from external service for missing port and transport.
2025-06-06 13:42:21 -05:00
Дамян Минков
f060ab9d26 feat(visitors): Updates buttons visible to visitors.
* feat(visitors): Allow participants pane button.

* feat(visitors): Do not count the local participant when in visitor mode.

* feat(visitors): Use same buttons on web and native.

* feat(visitors): Always show the visitors count.

It was shown only for the main participants.

* feat(visitors): Skips showing local in participants pane when visitor.
2025-06-05 16:44:47 -05:00
dependabot[bot]
df3dd2b14e chore(deps-dev): bump undici from 6.21.1 to 6.21.3
Bumps [undici](https://github.com/nodejs/undici) from 6.21.1 to 6.21.3.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.21.1...v6.21.3)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.21.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-04 22:31:28 +02:00
Saúl Ibarra Corretgé
be9e1136de fix(ts) apply import linting rules to TS files too 2025-06-04 22:04:36 +02:00
Saúl Ibarra Corretgé
e2337e8db8 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v1995.0.0+6de3a8ff...v2006.0.0+04c7ab17
2025-06-04 22:04:36 +02:00
Дамян Минков
293b6fa908 feat(jwt): New option to control user info check. (#16115)
* feat(jwt): New option to control user info check.

* squash: Rename.
2025-06-04 08:07:42 -05:00
dependabot[bot]
34da0ff99e chore(deps-dev): bump tar-fs from 3.0.8 to 3.0.9
Bumps [tar-fs](https://github.com/mafintosh/tar-fs) from 3.0.8 to 3.0.9.
- [Commits](https://github.com/mafintosh/tar-fs/compare/v3.0.8...v3.0.9)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-version: 3.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-04 14:34:20 +02:00
bgrozev
06713a4ffa fix: Handling inviteJigasiOnBackendTranscribing. (#16113)
* fix: Handling inviteJigasiOnBackendTranscribing.

* squash: Remove extra whitespace.
2025-06-03 19:42:36 -05:00
Дамян Минков
94813163e8 feat(jwt): Delay loading of avatar. (#16111)
* feat(jwt): Delay loading of avatar.

Only load avatar when jwt has been validated server-side (after connected). The drawback is not loading the avatar on pre-join page only the first time.

* squash: fix comments.
2025-06-03 13:59:29 -05:00
damencho
bb1eade1f0 feat(prosody): Allow filter_rayo to be loaded in other virtual hosts. 2025-06-03 13:59:18 -05:00
May
2f0ca6c839 fix(conference): show hours in duration instead of undefined 2025-06-03 09:30:19 -05:00
Jaya Allamsetty
45bbf06a85 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v1994.0.0+864d0f01...v1995.0.0+6de3a8ff
2025-06-02 11:54:38 -04:00
Jaya Allamsetty
042007adb5 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v1993.0.0+e4763700...v1994.0.0+864d0f01
2025-05-30 10:46:15 -04:00
Jaya Allamsetty
dc73d1e328 feat(conference) force reload page on shard changes. 2025-05-30 10:05:05 -04:00
Дамян Минков
db4c9666c3 feat(transcribing): Switch state on audio-recording-enabled. (#16094)
* feat(transcribing): Switch state on audio-recording-enabled.

* squash: Simplifies check based on suggestion.
2025-05-30 08:15:05 -05:00
Дамян Минков
45cfc0e112 feat(metadata): Append moderators to the list of main participants for jicofo. (#16097)
* feat(metadata): Append moderators to the list of main participants for jicofo.

* squash: Drop not needed line.
2025-05-29 21:36:04 -05:00
Дамян Минков
fa9aab953d feat(transcribing): Adds config to skip inviting jigasi on transcribing. (#16095)
* feat(transcribing): Adds config to skip inviting jigasi on transcribing.

* squash: Changes config value name.
2025-05-29 21:35:46 -05:00
damencho
c16f652378 feat(conference): Adds check for non-moderators dropping files anywhere. 2025-05-29 08:32:43 -05:00
damencho
6f3ae47a2e fix(prosody): Updates client permissions on granting owner rights.
Setting granted permissions in session should happen before sending self-presence on muc-pre-set-affiliation.
2025-05-29 08:32:43 -05:00
damencho
6afbff9b36 feat(file-sharing): Uses short term token for operations. 2025-05-29 08:32:43 -05:00
damencho
c7b6af1df3 feat(file-sharing): Uses feature to determine is upload enabled. 2025-05-29 08:32:43 -05:00
damencho
308faf71bf feat(prosody): Adds a short-live token module.
Use xep-0215 to retrieve it.
2025-05-29 08:32:43 -05:00
damencho
78efddc447 fix(permissions): Fix grant the granted features. 2025-05-29 08:32:43 -05:00
zxshen
d370e79237 fix(chat): avoid emoji accidents when sending messages (#15854)
* fix(chat): avoid emoji accidents when send messages

* fix: resolve ESLint formatting issues

Signed-off-by: Zhenxing Shen <zxshen@amazon.com>

---------

Signed-off-by: Zhenxing Shen <zxshen@amazon.com>
2025-05-29 07:22:39 -05:00
Jaya Allamsetty
97b01b910e chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v1991.0.0+80c2686a...v1993.0.0+e4763700
2025-05-28 14:23:08 -04:00
Дамян Минков
3bb28c4090 feat(metadata): Sends a list of main participants to jicofo. (#16088)
* feat(metadata): Sends a list of main participants to jicofo.

Drops adding metadata in the form, jicofo, jigasi and client are reading both and now we send initial message before the self-presence.

* squash: Fix error and make a shallow copy of metadata before sending to jicofo.
2025-05-28 11:01:59 -05:00
Saúl Ibarra Corretgé
589baab2ae fix(local-recording) defend against out of order events
We have observed some failed recordings which are lacking the EBML
header. The only way in which that seems plausible is if the
dataavailable event is received while processing the stop event. This is
allegedly not possible, but it's the only plausible explanation, so
let's defend against that.

Extend the timeslice back to 5s too.
2025-05-28 10:43:33 -05:00
Oğuzhan Selim Temiz
85fe5cf31e chore(deps): replace moment.js with day.js for locale handling (#15875)
* chore(deps): replace moment.js with day.js for locale handling and 
pin day.js version to 1.11.13 in package-lock.json

---------

Co-authored-by: Calin-Teodor <calin.chitu@8x8.com>
2025-05-28 15:29:32 +03:00
Saúl Ibarra Corretgé
7fabb33733 feat(android,ios) drop support for Firebase Dynamic Links
It's being sunset in August, after being deprecated for 2 years.

Ref: https://firebase.google.com/support/dynamic-links-faq?utm_source=deprecation_msa&utm_medium=email&utm_campaign=pone
2025-05-28 09:57:23 +03:00
damencho
5342712019 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v1989.0.0+dcb588a2...v1991.0.0+80c2686a
2025-05-27 11:51:35 -05:00
Calin-Teodor
deb0e36f84 fix(settings): trying to access getMetadata() of undefined 2025-05-27 17:45:36 +03:00
Viktor-Asheim
65e3886d43 fix(config) fixed incorrect documentation about toolbar buttons 2025-05-26 14:31:29 +02:00
Calinteodor
337435e738 feat(conference): File sharing over conference (#16067)
* While making the file size limit configurable, we managed to have files dragged and dropped over the whole conference window.
2025-05-26 15:24:51 +03:00
damencho
7a1f5885d0 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v1988.0.0+83c2ac30...v1989.0.0+dcb588a2
2025-05-24 15:09:52 -05:00
damencho
253f43ab9e fix(permissions): Adds an option to force-send permissions.
If backend modify permissions can force sending those on the initial presence.
2025-05-23 14:13:07 -05:00
damencho
f17a4387d9 feat(prosody): Check granted identity for recordings. 2025-05-23 11:58:28 -05:00
Saúl Ibarra Corretgé
7ac43abd03 fix(file-sharing) fix handling undefined metadata 2025-05-23 10:32:42 -05:00
Hristo Terezov
67b44f4406 fix(CC): Disable when config.transcription.enable=false
Disable the new CC tab when transcription.enable flag is false in config.
2025-05-22 20:13:43 -05:00
Saúl Ibarra Corretgé
7f601db349 feat(build,config) disable config whitelist in dev mode
Webpack will replace the code so the added condition because `if (true)`
in dev mode, which helps when one wants to override anything for
testing.
2025-05-22 16:43:31 +02:00
Saúl Ibarra Corretgé
26423f8e76 fix(file-sharing) rework sync
Rework sync so uploading multiple files at once or several moderators
uploading files simultaneously doesn't break synchronization.

The current room metadata plugin operates on <key,value> pairs and we
were using a generic "files" key and using a nested object as our value.
Since with every operation the entire object is replaced it's easy to
get out of sync because one needs to be sure to have the full state
before overwriting it.

This is not realistic.

We'll look into making the metadata plugin more flexible in order to
support add / delete operations also on nested objects, but for the time
being the following will suffice:

Use a key prefix, so each file has en entry in the room metadata, like
so: "files.<the file ID> -> file metadata". This means that when a file
is deleted we just empty the metadata. The metadata plugin doesn't
currently support removing existing keys.
2025-05-22 12:18:12 +02:00
177 changed files with 5109 additions and 2539 deletions

View File

@@ -83,10 +83,8 @@ dependencies {
// Firebase
// - Crashlytics
// - Dynamic Links
implementation 'com.google.firebase:firebase-analytics:21.3.0'
implementation 'com.google.firebase:firebase-crashlytics:18.4.3'
implementation 'com.google.firebase:firebase-dynamic-links:21.1.0'
}
implementation project(':sdk')

View File

@@ -4,7 +4,6 @@ import android.net.Uri;
import android.util.Log;
import com.google.firebase.crashlytics.FirebaseCrashlytics;
import com.google.firebase.dynamiclinks.FirebaseDynamicLinks;
import org.jitsi.meet.sdk.JitsiMeet;
import org.jitsi.meet.sdk.JitsiMeetActivity;
@@ -22,18 +21,6 @@ final class GoogleServicesHelper {
Log.d(activity.getClass().getSimpleName(), "Initializing Google Services");
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!JitsiMeet.isCrashReportingDisabled(activity));
FirebaseDynamicLinks.getInstance().getDynamicLink(activity.getIntent())
.addOnSuccessListener(activity, pendingDynamicLinkData -> {
Uri dynamicLink = null;
if (pendingDynamicLinkData != null) {
dynamicLink = pendingDynamicLinkData.getLink();
}
if (dynamicLink != null) {
activity.join(dynamicLink.toString());
}
});
}
}
}

View File

@@ -44,9 +44,6 @@ dependencies {
api "com.facebook.react:react-android:$rootProject.ext.rnVersion"
api "com.facebook.react:hermes-android:$rootProject.ext.rnVersion"
//noinspection GradleDynamicVersion
implementation 'org.webkit:android-jsc:+'
implementation 'com.facebook.fresco:animated-gif:2.5.0'
implementation 'com.dropbox.core:dropbox-core-sdk:4.0.1'
implementation 'com.jakewharton.timber:timber:5.0.1'

View File

@@ -10,13 +10,15 @@ package org.jitsi.meet.sdk;
* be found in the AUTHORS file in the root of the source tree.
*/
import android.media.MediaCodecInfo;
import androidx.annotation.Nullable;
import com.oney.WebRTCModule.webrtcutils.SoftwareVideoDecoderFactoryProxy;
import org.webrtc.EglBase;
import org.webrtc.HardwareVideoDecoderFactory;
import org.webrtc.PlatformSoftwareVideoDecoderFactory;
import org.webrtc.JitsiPlatformVideoDecoderFactory;
import org.webrtc.Predicate;
import org.webrtc.VideoCodecInfo;
import org.webrtc.VideoDecoder;
import org.webrtc.VideoDecoderFactory;
@@ -31,29 +33,34 @@ import java.util.LinkedHashSet;
public class JitsiVideoDecoderFactory implements VideoDecoderFactory {
private final VideoDecoderFactory hardwareVideoDecoderFactory;
private final VideoDecoderFactory softwareVideoDecoderFactory = new SoftwareVideoDecoderFactoryProxy();
private final @Nullable VideoDecoderFactory platformSoftwareVideoDecoderFactory;
private final VideoDecoderFactory platformSoftwareVideoDecoderFactory;
/**
* Predicate to filter out the AV1 hardware decoder, as we've seen decoding issues with it.
*/
private static final String GOOGLE_AV1_DECODER = "c2.google.av1";
private static final Predicate<MediaCodecInfo> hwCodecPredicate = arg -> {
// Filter out the Google AV1 codec.
return !arg.getName().startsWith(GOOGLE_AV1_DECODER);
};
private static final Predicate<MediaCodecInfo> swCodecPredicate = arg -> {
// Noop, just making sure we can customize it easily if needed.
return true;
};
/**
* Create decoder factory using default hardware decoder factory.
*/
public JitsiVideoDecoderFactory(@Nullable EglBase.Context eglContext) {
this.hardwareVideoDecoderFactory = new HardwareVideoDecoderFactory(eglContext);
this.platformSoftwareVideoDecoderFactory = new PlatformSoftwareVideoDecoderFactory(eglContext);
}
/**
* Create decoder factory using explicit hardware decoder factory.
*/
JitsiVideoDecoderFactory(VideoDecoderFactory hardwareVideoDecoderFactory) {
this.hardwareVideoDecoderFactory = hardwareVideoDecoderFactory;
this.platformSoftwareVideoDecoderFactory = null;
this.hardwareVideoDecoderFactory = new HardwareVideoDecoderFactory(eglContext, hwCodecPredicate);
this.platformSoftwareVideoDecoderFactory = new JitsiPlatformVideoDecoderFactory(eglContext, swCodecPredicate);
}
@Override
public @Nullable VideoDecoder createDecoder(VideoCodecInfo codecType) {
VideoDecoder softwareDecoder = softwareVideoDecoderFactory.createDecoder(codecType);
final VideoDecoder hardwareDecoder = hardwareVideoDecoderFactory.createDecoder(codecType);
if (softwareDecoder == null && platformSoftwareVideoDecoderFactory != null) {
if (softwareDecoder == null) {
softwareDecoder = platformSoftwareVideoDecoderFactory.createDecoder(codecType);
}
if (hardwareDecoder != null && softwareDecoder != null) {
@@ -70,10 +77,7 @@ public class JitsiVideoDecoderFactory implements VideoDecoderFactory {
supportedCodecInfos.addAll(Arrays.asList(softwareVideoDecoderFactory.getSupportedCodecs()));
supportedCodecInfos.addAll(Arrays.asList(hardwareVideoDecoderFactory.getSupportedCodecs()));
if (platformSoftwareVideoDecoderFactory != null) {
supportedCodecInfos.addAll(
Arrays.asList(platformSoftwareVideoDecoderFactory.getSupportedCodecs()));
}
supportedCodecInfos.addAll(Arrays.asList(platformSoftwareVideoDecoderFactory.getSupportedCodecs()));
return supportedCodecInfos.toArray(new VideoCodecInfo[supportedCodecInfos.size()]);
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2018 The WebRTC project authors. All Rights Reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree. An additional intellectual property rights grant can be found
* in the file PATENTS. All contributing project authors may
* be found in the AUTHORS file in the root of the source tree.
*/
package org.webrtc;
import android.media.MediaCodecInfo;
import androidx.annotation.Nullable;
/** Factory for Android platform software VideoDecoders. */
public class JitsiPlatformVideoDecoderFactory extends MediaCodecVideoDecoderFactory {
/**
* Default allowed predicate.
*/
private static final Predicate<MediaCodecInfo> defaultAllowedPredicate =
codecInfo -> {
// We only want to use the platform software codecs.
return MediaCodecUtils.isSoftwareOnly(codecInfo);
};
/**
* Creates a PlatformSoftwareVideoDecoderFactory that supports surface texture rendering.
*
* @param sharedContext The textures generated will be accessible from this context. May be null,
* this disables texture support.
*/
public JitsiPlatformVideoDecoderFactory(@Nullable EglBase.Context sharedContext) {
super(sharedContext, defaultAllowedPredicate);
}
public JitsiPlatformVideoDecoderFactory(@Nullable EglBase.Context sharedContext, @Nullable Predicate<MediaCodecInfo> codecAllowedPredicate) {
super(sharedContext, codecAllowedPredicate == null ? defaultAllowedPredicate : codecAllowedPredicate.and(defaultAllowedPredicate));
}
}

View File

@@ -3,7 +3,7 @@ rootProject.name = 'jitsi-meet'
include ':app', ':sdk'
include ':react-native-amplitude'
project(':react-native-amplitude').projectDir = new File(rootProject.projectDir, '../node_modules/@amplitude/react-native/android')
project(':react-native-amplitude').projectDir = new File(rootProject.projectDir, '../node_modules/@amplitude/analytics-react-native/android')
include ':react-native-async-storage'
project(':react-native-async-storage').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-async-storage/async-storage/android')
include ':react-native-background-timer'

View File

@@ -89,7 +89,7 @@ import {
setVideoMuted,
setVideoUnmutePermissions
} from './react/features/base/media/actions';
import { MEDIA_TYPE, VIDEO_TYPE } from './react/features/base/media/constants';
import { MEDIA_TYPE, VIDEO_MUTISM_AUTHORITY, VIDEO_TYPE } from './react/features/base/media/constants';
import {
getStartWithAudioMuted,
getStartWithVideoMuted,
@@ -205,23 +205,6 @@ function sendData(command, value) {
room.sendCommand(command, { value });
}
/**
* Mute or unmute local audio stream if it exists.
* @param {boolean} muted - if audio stream should be muted or unmuted.
*/
function muteLocalAudio(muted) {
APP.store.dispatch(setAudioMuted(muted));
}
/**
* Mute or unmute local video stream if it exists.
* @param {boolean} muted if video stream should be muted or unmuted.
*
*/
function muteLocalVideo(muted) {
APP.store.dispatch(setVideoMuted(muted));
}
/**
* A queue for the async replaceLocalTrack action so that multiple audio
* replacements cannot happen simultaneously. This solves the issue where
@@ -708,11 +691,10 @@ export default {
* Simulates toolbar button click for audio mute. Used by shortcuts and API.
*
* @param {boolean} mute true for mute and false for unmute.
* @param {boolean} [showUI] when set to false will not display any error
* dialogs in case of media permissions error.
* @returns {Promise}
*/
async muteAudio(mute, showUI = true) {
async muteAudio(mute) {
const state = APP.store.getState();
if (!mute
@@ -731,47 +713,7 @@ export default {
return;
}
// Not ready to modify track's state yet
if (!this._localTracksInitialized) {
// This will only modify base/media.audio.muted which is then synced
// up with the track at the end of local tracks initialization.
muteLocalAudio(mute);
this.updateAudioIconEnabled();
return;
} else if (this.isLocalAudioMuted() === mute) {
// NO-OP
return;
}
const localAudio = getLocalJitsiAudioTrack(APP.store.getState());
if (!localAudio && !mute) {
const maybeShowErrorDialog = error => {
showUI && APP.store.dispatch(notifyMicError(error));
};
APP.store.dispatch(gumPending([ MEDIA_TYPE.AUDIO ], IGUMPendingState.PENDING_UNMUTE));
await createLocalTracksF({ devices: [ 'audio' ] })
.then(([ audioTrack ]) => audioTrack)
.catch(error => {
maybeShowErrorDialog(error);
// Rollback the audio muted status by using null track
return null;
})
.then(async audioTrack => {
await this._maybeApplyAudioMixerEffect(audioTrack);
return this.useAudioStream(audioTrack);
})
.finally(() => {
APP.store.dispatch(gumPending([ MEDIA_TYPE.AUDIO ], IGUMPendingState.NONE));
});
} else {
muteLocalAudio(mute);
}
await APP.store.dispatch(setAudioMuted(mute, true));
},
/**
@@ -801,10 +743,9 @@ export default {
/**
* Simulates toolbar button click for video mute. Used by shortcuts and API.
* @param mute true for mute and false for unmute.
* @param {boolean} [showUI] when set to false will not display any error
* dialogs in case of media permissions error.
*/
muteVideo(mute, showUI = true) {
muteVideo(mute) {
if (this.videoSwitchInProgress) {
logger.warn('muteVideo - unable to perform operations while video switch is in progress');
@@ -825,60 +766,7 @@ export default {
return;
}
// If not ready to modify track's state yet adjust the base/media
if (!this._localTracksInitialized) {
// This will only modify base/media.video.muted which is then synced
// up with the track at the end of local tracks initialization.
muteLocalVideo(mute);
this.setVideoMuteStatus();
return;
} else if (this.isLocalVideoMuted() === mute) {
// NO-OP
return;
}
const localVideo = getLocalJitsiVideoTrack(state);
if (!localVideo && !mute && !this.isCreatingLocalTrack) {
const maybeShowErrorDialog = error => {
showUI && APP.store.dispatch(notifyCameraError(error));
};
this.isCreatingLocalTrack = true;
APP.store.dispatch(gumPending([ MEDIA_TYPE.VIDEO ], IGUMPendingState.PENDING_UNMUTE));
// Try to create local video if there wasn't any.
// This handles the case when user joined with no video
// (dismissed screen sharing screen or in audio only mode), but
// decided to add it later on by clicking on muted video icon or
// turning off the audio only mode.
//
// FIXME when local track creation is moved to react/redux
// it should take care of the use case described above
createLocalTracksF({ devices: [ 'video' ] })
.then(([ videoTrack ]) => videoTrack)
.catch(error => {
// FIXME should send some feedback to the API on error ?
maybeShowErrorDialog(error);
// Rollback the video muted status by using null track
return null;
})
.then(videoTrack => {
logger.debug(`muteVideo: calling useVideoStream for track: ${videoTrack}`);
return this.useVideoStream(videoTrack);
})
.finally(() => {
this.isCreatingLocalTrack = false;
APP.store.dispatch(gumPending([ MEDIA_TYPE.VIDEO ], IGUMPendingState.NONE));
});
} else {
// FIXME show error dialog if it fails (should be handled by react)
muteLocalVideo(mute);
}
APP.store.dispatch(setVideoMuted(mute, VIDEO_MUTISM_AUTHORITY.USER, true));
},
/**

View File

@@ -117,6 +117,11 @@ var config = {
// Will replace ice candidates IPs with invalid ones in order to fail ice.
// failICE: true,
// When running on Spot TV, this controls whether to show the recording consent dialog.
// If false (default), Spot instances will not show the recording consent dialog.
// If true, Spot instances will show the recording consent dialog like regular clients.
// showSpotConsentDialog: false,
},
// Disables moderator indicators.
@@ -509,7 +514,11 @@ var config = {
// // Disables everything related to closed captions - the tab in the chat area, the button in the menu,
// // subtitles on stage and the "Show subtitles on stage" checkbox in the settings.
// // Note: Starting transcriptions from the recording dialog will still work.
// disableClosedCaptions: false
// disableClosedCaptions: false,
// // Whether to invite jigasi when backend transcriptions are enabled (asyncTranscription is true in metadata).
// // By default, we invite it.
// inviteJigasiOnBackendTranscribing: true,
// },
// Misc
@@ -839,8 +848,7 @@ var config = {
// some other values in config.js to be enabled. Also, the "profile" button will
// not display for users with a JWT.
// Notes:
// - it's impossible to choose which buttons go in the "More actions" menu
// - it's impossible to control the placement of buttons
// - it's possible to reorder the buttons in the maintoolbar by changing the order of the mainToolbarButtons
// - 'desktop' controls the "Share your screen" button
// - if `toolbarButtons` is undefined, we fallback to enabling all buttons on the UI
// toolbarButtons: [
@@ -1123,10 +1131,6 @@ var config = {
// The Amplitude APP Key:
// amplitudeAPPKey: '<APP_KEY>',
// Enables Amplitude UTM tracking:
// Default value is false.
// amplitudeIncludeUTM: false,
// Obfuscates room name sent to analytics (amplitude, rtcstats)
// Default value is false.
// obfuscateRoomName: false,
@@ -1270,9 +1274,6 @@ var config = {
// disableDeepLinking: false,
// The deeplinking config.
// For information about the properties of
// deeplinking.[ios/android].dynamicLink check:
// https://firebase.google.com/docs/dynamic-links/create-manually
// deeplinking: {
//
// // The desktop deeplinking config, disabled by default.
@@ -1301,13 +1302,6 @@ var config = {
// appScheme: 'org.jitsi.meet',
// // Custom URL for downloading ios mobile app.
// downloadLink: 'https://itunes.apple.com/us/app/jitsi-meet/id1165103905',
// dynamicLink: {
// apn: 'org.jitsi.meet',
// appCode: 'w2atb',
// customDomain: undefined,
// ibi: 'com.atlassian.JitsiMeet.ios',
// isi: '1165103905'
// }
// },
// // The android deeplinking config.
@@ -1320,13 +1314,6 @@ var config = {
// // Android app package name.
// appPackage: 'org.jitsi.meet',
// fDroidUrl: 'https://f-droid.org/en/packages/org.jitsi.meet/',
// dynamicLink: {
// apn: 'org.jitsi.meet',
// appCode: 'w2atb',
// customDomain: undefined,
// ibi: 'com.atlassian.JitsiMeet.ios',
// isi: '1165103905'
// }
// }
// },
@@ -1374,18 +1361,11 @@ var config = {
// disableKick: true,
// // If set to true the 'Grant moderator' button will be disabled.
// disableGrantModerator: true,
// // If set to true the 'Send private message' button will be disabled.
// disablePrivateChat: true,
// // If set to 'all' the 'Private chat' button will be disabled for all participants.
// // If set to 'allow-moderator-chat' the 'Private chat' button will be available for chats with moderators.
// disablePrivateChat: 'all' | 'allow-moderator-chat',
// },
// Endpoint that enables support for salesforce integration with in-meeting resource linking
// This is required for:
// listing the most recent records - salesforceUrl/records/recents
// searching records - salesforceUrl/records?text=${text}
// retrieving record details - salesforceUrl/records/${id}?type=${type}
// and linking the meeting - salesforceUrl/sessions/${sessionId}/records/${id}
//
// salesforceUrl: 'https://api.example.com/',
// If set to true all muting operations of remote participants will be disabled.
// disableRemoteMute: true,
@@ -1410,6 +1390,13 @@ var config = {
logoClickUrl: 'https://example-company.org',
// The url used for the image used as logo
logoImageUrl: 'https://example.com/logo-img.png',
// Endpoint that enables support for salesforce integration with in-meeting resource linking
// This is required for:
// listing the most recent records - salesforceUrl/records/recents
// searching records - salesforceUrl/records?text=${text}
// retrieving record details - salesforceUrl/records/${id}?type=${type}
// and linking the meeting - salesforceUrl/sessions/${sessionId}/records/${id}
// salesforceUrl: 'https://api.example.com/',
// Overwrite for pool of background images for avatars
avatarBackgrounds: ['url(https://example.com/avatar-background-1.png)', '#FFF'],
// The lobby/prejoin screen background
@@ -1591,6 +1578,9 @@ var config = {
// tokenAuthUrlAutoRedirect: false
// An option to respect the context.tenant jwt field compared to the current tenant from the url
// tokenRespectTenant: false,
// An option to get for user info (name, picture, email) in the token outside the user context.
// Can be used with Firebase tokens.
// tokenGetUserInfoOutOfContext: false,
// You can put an array of values to target different entity types in the invite dialog.
// Valid values are "phone", "room", "sip", "user", "videosipgw" and "email"
@@ -1790,6 +1780,13 @@ var config = {
// // The minimum number of participants that must be in the call for
// // the top panel layout to be used.
// minParticipantCountForTopPanel: 50,
// // The width of the filmstrip on joining meeting. Can be resized afterwards.
// initialWidth: 400,
// // Whether the draggable resize bar of the filmstrip is always visible. Setting this to true will make
// // the filmstrip always visible in case `disableResizable` is false.
// alwaysShowResizeBar: true,
// },
// Tile view related config options.
@@ -1898,6 +1895,8 @@ var config = {
// apiUrl: 'https://example.com',
// // Whether the file sharing service is enabled or not.
// enabled: true,
// // Maximum file size limit (-1 value disables any file size limit check)
// maxFileSize: 50,
// },
};

View File

@@ -58,28 +58,21 @@ VirtualHost "jitmeet.example.com"
key = "/etc/prosody/certs/jitmeet.example.com.key";
certificate = "/etc/prosody/certs/jitmeet.example.com.crt";
}
av_moderation_component = "avmoderation.jitmeet.example.com"
speakerstats_component = "speakerstats.jitmeet.example.com"
end_conference_component = "endconference.jitmeet.example.com"
-- we need bosh
modules_enabled = {
"bosh";
"websocket";
"smacks";
"ping"; -- Enable mod_ping
"speakerstats";
"external_services";
"features_identity";
"conference_duration";
"end_conference";
"muc_lobby_rooms";
"muc_breakout_rooms";
"av_moderation";
"room_metadata";
}
c2s_require_encryption = false
lobby_muc = "lobby.jitmeet.example.com"
breakout_rooms_muc = "breakout.jitmeet.example.com"
room_metadata_component = "metadata.jitmeet.example.com"
main_muc = "conference.jitmeet.example.com"
-- muc_lobby_whitelist = { "recorder.jitmeet.example.com" } -- Here we can whitelist jibri to enter lobby enabled rooms
@@ -155,6 +148,9 @@ Component "endconference.jitmeet.example.com" "end_conference"
Component "avmoderation.jitmeet.example.com" "av_moderation_component"
muc_component = "conference.jitmeet.example.com"
Component "filesharing.jitmeet.example.com" "filesharing_component"
muc_component = "conference.jitmeet.example.com"
Component "lobby.jitmeet.example.com" "muc"
storage = "memory"
restrict_room_creation = true

View File

@@ -192,17 +192,6 @@ var interfaceConfig = {
// NATIVE_APP_NAME: 'Jitsi Meet',
/**
* Specify Firebase dynamic link properties for the mobile apps.
*/
// MOBILE_DYNAMIC_LINK: {
// APN: 'org.jitsi.meet',
// APP_CODE: 'w2atb',
// CUSTOM_DOMAIN: undefined,
// IBI: 'com.atlassian.JitsiMeet.ios',
// ISI: '1165103905'
// },
/**
* Hide the logo on the deep linking pages.
*/

View File

@@ -19,7 +19,6 @@ target 'JitsiMeet' do
pod 'Firebase/Analytics', '~> 8.0'
pod 'Firebase/Crashlytics', '~> 8.0'
pod 'Firebase/DynamicLinks', '~> 8.0'
end
target 'JitsiMeetSDK' do

View File

@@ -1,15 +1,11 @@
PODS:
- Amplitude (8.18.0):
- AnalyticsConnector (~> 1.0.0)
- amplitude-react-native (2.17.3):
- Amplitude (= 8.18.0)
- amplitude-react-native (1.4.13):
- React-Core
- AnalyticsConnector (1.0.3)
- AppAuth (1.7.5):
- AppAuth/Core (= 1.7.5)
- AppAuth/ExternalUserAgent (= 1.7.5)
- AppAuth/Core (1.7.5)
- AppAuth/ExternalUserAgent (1.7.5):
- AppAuth (1.7.6):
- AppAuth/Core (= 1.7.6)
- AppAuth/ExternalUserAgent (= 1.7.6)
- AppAuth/Core (1.7.6)
- AppAuth/ExternalUserAgent (1.7.6):
- AppAuth/Core
- boost (1.84.0)
- CocoaLumberjack (3.7.4):
@@ -27,9 +23,6 @@ PODS:
- Firebase/Crashlytics (8.15.0):
- Firebase/CoreOnly
- FirebaseCrashlytics (~> 8.15.0)
- Firebase/DynamicLinks (8.15.0):
- Firebase/CoreOnly
- FirebaseDynamicLinks (~> 8.15.0)
- FirebaseAnalytics (8.15.0):
- FirebaseAnalytics/AdIdSupport (= 8.15.0)
- FirebaseCore (~> 8.0)
@@ -64,8 +57,6 @@ PODS:
- GoogleUtilities/Environment (~> 7.7)
- nanopb (~> 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- FirebaseDynamicLinks (8.15.0):
- FirebaseCore (~> 8.0)
- FirebaseInstallations (8.15.0):
- FirebaseCore (~> 8.0)
- GoogleUtilities/Environment (~> 7.7)
@@ -141,18 +132,18 @@ PODS:
- hermes-engine (0.75.5):
- hermes-engine/Pre-built (= 0.75.5)
- hermes-engine/Pre-built (0.75.5)
- JitsiWebRTC (124.0.1)
- libwebp (1.3.2):
- libwebp/demux (= 1.3.2)
- libwebp/mux (= 1.3.2)
- libwebp/sharpyuv (= 1.3.2)
- libwebp/webp (= 1.3.2)
- libwebp/demux (1.3.2):
- JitsiWebRTC (124.0.2)
- libwebp (1.5.0):
- libwebp/demux (= 1.5.0)
- libwebp/mux (= 1.5.0)
- libwebp/sharpyuv (= 1.5.0)
- libwebp/webp (= 1.5.0)
- libwebp/demux (1.5.0):
- libwebp/webp
- libwebp/mux (1.3.2):
- libwebp/mux (1.5.0):
- libwebp/demux
- libwebp/sharpyuv (1.3.2)
- libwebp/webp (1.3.2):
- libwebp/sharpyuv (1.5.0)
- libwebp/webp (1.5.0):
- libwebp/sharpyuv
- nanopb (2.30908.0):
- nanopb/decode (= 2.30908.0)
@@ -1791,14 +1782,13 @@ PODS:
- Yoga (0.0.0)
DEPENDENCIES:
- "amplitude-react-native (from `../node_modules/@amplitude/react-native`)"
- "amplitude-react-native (from `../node_modules/@amplitude/analytics-react-native`)"
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- CocoaLumberjack (= 3.7.4)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- Firebase/Analytics (~> 8.0)
- Firebase/Crashlytics (~> 8.0)
- Firebase/DynamicLinks (~> 8.0)
- fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`)
- "giphy-react-native-sdk (from `../node_modules/@giphy/react-native-sdk`)"
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
@@ -1889,8 +1879,6 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- Amplitude
- AnalyticsConnector
- AppAuth
- CocoaLumberjack
- Firebase
@@ -1898,7 +1886,6 @@ SPEC REPOS:
- FirebaseCore
- FirebaseCoreDiagnostics
- FirebaseCrashlytics
- FirebaseDynamicLinks
- FirebaseInstallations
- Giphy
- GoogleAppMeasurement
@@ -1917,7 +1904,7 @@ SPEC REPOS:
EXTERNAL SOURCES:
amplitude-react-native:
:path: "../node_modules/@amplitude/react-native"
:path: "../node_modules/@amplitude/analytics-react-native"
boost:
:podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec"
DoubleConversion:
@@ -2095,10 +2082,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
Amplitude: 184def4f87aa26f94a93a7faa334e06b1cae704d
amplitude-react-native: 6b7a1d30627233fe6f03741109831561d0a5f69c
AnalyticsConnector: a53214d38ae22734c6266106c0492b37832633a9
AppAuth: 501c04eda8a8d11f179dbe8637b7a91bb7e5d2fa
amplitude-react-native: d0039a3ce502eb441ba818af1b8c8ba710ef16e7
AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73
boost: 4cb898d0bf20404aab1850c656dcea009429d6c1
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
@@ -2108,7 +2093,6 @@ SPEC CHECKSUMS:
FirebaseCore: 5743c5785c074a794d35f2fff7ecc254a91e08b1
FirebaseCoreDiagnostics: 92e07a649aeb66352b319d43bdd2ee3942af84cb
FirebaseCrashlytics: feb07e4e9187be3c23c6a846cce4824e5ce2dd0b
FirebaseDynamicLinks: 1dc816ef789c5adac6fede0b46d11478175c70e4
FirebaseInstallations: 40bd9054049b2eae9a2c38ef1c3dd213df3605cd
fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120
Giphy: 83628960ed04e1c3428ff1b4fb2b027f65e82f50
@@ -2121,8 +2105,8 @@ SPEC CHECKSUMS:
GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
hermes-engine: c9fe5870af65876125fdbbf833071b6f329db30d
JitsiWebRTC: d0ae5fd6a81e771bfd82c2ee6c6bb542ebd65ee8
libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
JitsiWebRTC: b47805ab5668be38e7ee60e2258f49badfe8e1d0
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96
ObjectiveDropboxOfficial: fe206ce8c0bc49976c249d472db7fdbc53ebbd53
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
@@ -2209,6 +2193,6 @@ SPEC CHECKSUMS:
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
Yoga: 1dd9dabb9df8fe08f12cd522eae04a2da0e252eb
PODFILE CHECKSUM: 4f6abcf3cec0d9e8e1d5f5d81a35d99adde9ae45
PODFILE CHECKSUM: 8ff981f2324fa4b013b6690257898dcd2be9d3c3
COCOAPODS: 1.16.2

View File

@@ -539,7 +539,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if test \"$PRODUCT_BUNDLE_IDENTIFIER\" = \"com.atlassian.JitsiMeet.ios\"; then\n ENTITLEMENTS_PLIST=\"$PROJECT_DIR/app.entitlements\"\n \n /usr/libexec/PlistBuddy -c \"Add :com.apple.developer.avfoundation.multitasking-camera-access bool 1\" $ENTITLEMENTS_PLIST\nfi\n";
shellScript = "#if test \"$PRODUCT_BUNDLE_IDENTIFIER\" = \"com.atlassian.JitsiMeet.ios\"; then\n# ENTITLEMENTS_PLIST=\"$PROJECT_DIR/app.entitlements\"\n# \n# /usr/libexec/PlistBuddy -c \"Add :com.apple.developer.avfoundation.multitasking-camera-access bool 1\" $ENTITLEMENTS_PLIST\n#fi\n";
};
69BC5020DBE393B56BD76636 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;

View File

@@ -54,19 +54,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// MARK: Linking delegate methods
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
if self.appContainsRealServiceInfoPlist() {
let handled = DynamicLinks.dynamicLinks().handleUniversalLink(userActivity.webpageURL!) { dynamicLink, error in
if let firebaseUrl = self.extractURL(from: dynamicLink) {
userActivity.webpageURL = firebaseUrl
JitsiMeet.sharedInstance().application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}
if handled {
return handled
}
}
return JitsiMeet.sharedInstance().application(application, continue: userActivity, restorationHandler: restorationHandler)
}
@@ -75,16 +62,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return false
}
var openUrl = url
if self.appContainsRealServiceInfoPlist() {
if let dynamicLink = DynamicLinks.dynamicLinks().dynamicLink(fromCustomSchemeURL: url),
let firebaseUrl = self.extractURL(from: dynamicLink) {
openUrl = firebaseUrl
}
}
return JitsiMeet.sharedInstance().application(app, open: openUrl, options: options)
return JitsiMeet.sharedInstance().application(app, open: url, options: options)
}
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
@@ -97,13 +75,4 @@ extension AppDelegate {
func appContainsRealServiceInfoPlist() -> Bool {
return InfoPlistUtil.containsRealServiceInfoPlist(in: Bundle.main)
}
func extractURL(from dynamicLink: DynamicLink?) -> URL? {
guard let dynamicLink = dynamicLink,
let dynamicLinkURL = dynamicLink.url,
dynamicLink.matchType == .unique || dynamicLink.matchType == .default else {
return nil
}
return dynamicLinkURL
}
}

View File

@@ -109,6 +109,7 @@
}
},
"chat": {
"disabled": "L'invio di messaggi in chat è disabilitato.",
"enter": "Entra nella conversazione",
"error": "Errore: il tuo messaggio non è stato inviato. Motivo: {{error}}",
"fieldPlaceHolder": "Scrivi qui il tuo messaggio",
@@ -546,8 +547,10 @@
"downloadFailedDescription": "Si prega di riprovare.",
"downloadFailedTitle": "Download non riuscito",
"downloadFile": "Download",
"dragAndDrop": "Trascina e rilascia i file qui",
"fileAlreadyUploaded": "Questo file è già stato caricato nella riunione",
"dragAndDrop": "Trascina e rilascia i file qui o da qualsiasi altra parte nella schermata",
"fileAlreadyUploaded": "Questo file è già stato caricato nella riunione.",
"fileTooLargeDescription": "Assicurati che il file non superi {{ maxFileSize }}.",
"fileTooLargeTitle": "Il file selezionato è troppo grande",
"removeFile": "Rimuovi",
"uploadFailedDescription": "Si prega di riprovare.",
"uploadFailedTitle": "Caricamento non riuscito",
@@ -906,6 +909,7 @@
"visitorInQueue": " ({{count}} in attesa)",
"visitorRequests": " ({{count}} richiesta/e)",
"visitors": "Spettatori {{count}}",
"visitorsList": "Spettatori ({{count}})",
"waitingLobby": "({{count}}) in attesa"
},
"search": "Cerca partecipanti",
@@ -946,7 +950,7 @@
},
"results": {
"changeVote": "Cambia voto",
"empty": "Non ci sono ancora sondaggi in questa riunione. Crea un sondaggio qui!",
"empty": "Non ci sono ancora sondaggi in questa riunione.",
"hideDetailedResults": "Nascondi dettagli",
"showDetailedResults": "Mostra dettagli",
"vote": "Voti"

View File

@@ -67,13 +67,18 @@
"renameBreakoutRoom": "重命名分组讨论室",
"sendToBreakoutRoom": "将参会者移至:"
},
"defaultName": "分组讨论室#{{index}}",
"breakoutList": "分组讨论室列表",
"buttonLabel": "分组讨论室",
"defaultName": "分组讨论室 #{{index}}",
"hideParticipantList": "隐藏成员列表",
"mainRoom": "主会议室",
"notifications": {
"joined": "正在加入“{{name}}”分组讨论室",
"joinedMainRoom": "正在加入主会议室",
"joinedTitle": "分组讨论室"
}
},
"showParticipantList": "显示成员列表",
"title": "分组讨论室"
},
"calendarSync": {
"addMeetingURL": "添加会议链接",
@@ -104,10 +109,11 @@
}
},
"chat": {
"disabled": "聊天已禁用",
"enter": "加入会议室",
"error": "错误:你的消息未发送。原因:{{error}}",
"fieldPlaceHolder": "在这里输入你的信息",
"lobbyChatMessageTo": "大厅聊天消息发送至{{recipient}}",
"lobbyChatMessageTo": "等候室聊天消息发送至{{recipient}}",
"message": "信息",
"messageAccessibleTitle": "{{user}}",
"messageAccessibleTitleMe": "我:",
@@ -116,19 +122,28 @@
"newMessages": "新信息",
"nickname": {
"popover": "选择一个昵称",
"title": "输入一个昵称用于聊天",
"titleWithPolls": "输入一个昵称用于聊天和投票"
"title": "输入昵称用于聊天",
"titleWithCC": "输入昵称用于聊天和字幕)",
"titleWithPolls": "输入昵称(用于聊天和投票)",
"titleWithPollsAndCC": "输入昵称(用于聊天、投票和字幕)",
"titleWithPollsAndCCAndFileSharing": "输入昵称(用于聊天、投票、字幕和文件共享)"
},
"noMessagesMessage": "会议中还没有消息,在这里开始谈话吧!",
"privateNotice": "与{{recipient}}的私聊",
"sendButton": "发送",
"smileysPanel": "表情符号面板",
"systemDisplayName": "系統",
"tabs": {
"chat": "聊天",
"closedCaptions": "字幕",
"fileSharing": "文件共享",
"polls": "投票"
},
"title": "聊天",
"titleWithPolls": "聊天和投票",
"titleWithCC": "字幕",
"titleWithFeatures": "聊天和",
"titleWithFileSharing": "文件",
"titleWithPolls": "投票",
"you": "你"
},
"chromeExtensionBanner": {
@@ -138,6 +153,10 @@
"dontShowAgain": "不要再问我了",
"installExtensionText": "安装用于Google日历和Office 365集成的扩展插件"
},
"closedCaptionsTab": {
"emptyState": "字幕将在主持人开启后显示",
"startClosedCaptionsButton": "开启字幕"
},
"connectingOverlay": {
"joiningRoom": "连接会议中……"
},
@@ -206,7 +225,7 @@
"descriptionNew": "没反应?我们已经尝试在{{app}}的电脑应用程序中加入会议。<br /><br />你可以重试,或在网页端中启动。",
"descriptionWithoutWeb": "没反应?我们已经尝试在{{app}}的电脑应用程序中加入会议。",
"downloadApp": "下载APP",
"downloadMobileApp": "App Store下载",
"downloadMobileApp": "前往App Store下载",
"ifDoNotHaveApp": "如果你还没有这个APP",
"ifHaveApp": "如果你已经拥有该APP",
"joinInApp": "使用APP加入此会议",
@@ -214,7 +233,9 @@
"joinInBrowser": "在浏览器中加入",
"launchMeetingLabel": "你希望如何加入此会议?",
"launchWebButton": "在网页中启动",
"noMobileApp": "还没安装APP",
"noDesktopApp": "还没安装桌面客户端",
"noMobileApp": "还没有安装手机App",
"or": "或",
"termsAndConditions": "继续操作即表示你同意我们的<a href='{{termsAndConditionsLink}}' rel='noopener noreferrer' target='_blank'>条款和条件。</a>",
"title": "正在{{app}}中加入会议……",
"titleNew": "正在启动你的会议……",
@@ -250,12 +271,16 @@
"dialog": {
"Back": "返回",
"Cancel": "取消",
"IamHost": "我是主持",
"IamHost": "登录主持",
"Ok": "确定",
"Remove": "移除",
"Share": "分享",
"Submit": "提交",
"WaitForHostMsg": "会议尚未开始。如果你是主持人,请进行身份验证。否则,请等待主持人加入。",
"Understand": "我已知晓,暂时保持静音",
"UnderstandAndUnmute": "我已知晓,请为我解除静音",
"WaitForHostMsg": "会议尚未开始,主持人还未入会。如需成为主持人请先登录,或耐心等待会议开始。",
"WaitForHostNoAuthMsg": "会议尚未开始,暂无主持人入会,请耐心等待",
"WaitingForHostButton": "等待主持人",
"WaitingForHostTitle": "正在等待主持人加入……",
"Yes": "是",
"accessibilityLabel": {
@@ -269,6 +294,8 @@
"addMeetingNote": "添加本次会议的备注",
"addOptionalNote": "添加备注(可选):",
"allow": "允许",
"allowToggleCameraDialog": "你是否允许{{initiatorName}}切换你的摄像头?",
"allowToggleCameraTitle": "允许切换摄像头?",
"alreadySharedVideoMsg": "其他参会者正在分享视频,本次会议仅支持同时分享一个视频。",
"alreadySharedVideoTitle": "同一时间只允许一个视频分享",
"applicationWindow": "应用程序窗口",
@@ -287,6 +314,7 @@
"conferenceReloadMsg": "我们正在努力修复此问题,将在{{seconds}}秒后重新连接……",
"conferenceReloadTitle": "糟了,好像有什么东西出错了。",
"confirm": "确认",
"confirmBack": "返回",
"confirmNo": "否",
"confirmYes": "是",
"connectError": "发生错误,无法连接至会议!",
@@ -295,19 +323,22 @@
"contactSupport": "联系支持",
"copied": "已复制",
"copy": "复制",
"demoteParticipantDialog": "确定将该成员调整为观众吗?",
"demoteParticipantTitle": "调整为观众",
"dismiss": "忽略",
"displayNameRequired": "嗨!你叫什么名字?",
"done": "完成",
"e2eeDescription": "请注意端到端加密目前处于实验阶段开启端到端加密将禁用部分服务器端提供的服务例如通过电话加入会议。另外通过网页版加入会议还需要使用支持Insertable Stream的浏览器。",
"e2eeDisabledDueToMaxModeDescription": "由于会议中的人数过多,无法开启端到端加密。",
"e2eeLabel": "开启端到端加密",
"e2eeWarning": "警告:本次会议中并不是所有的参会者都支持端到端加密。如果启它,他们将无法看到或听到你。",
"e2eeWarning": "警告:本次会议中并不是所有的参会者都支持端到端加密。如果启它,他们将无法看到或听到你。",
"e2eeWillDisableDueToMaxModeDescription": "警告:如果有更多人加入会议,端到端加密将自动禁用。",
"embedMeeting": "嵌入会议",
"enterDisplayName": "请在此输入你的名",
"enterDisplayName": "请输入你的名",
"error": "错误",
"errorRoomCreationRestriction": "加入过于频繁,请稍后再试。",
"gracefulShutdown": "我们的服务目前正在维护中,请稍后再试。",
"grantModeratorDialog": "确定要授予{{participantName}}主持人权限吗?",
"grantModeratorDialog": "确定{{participantName}}设为主持人吗?",
"grantModeratorTitle": "授予主持人权限",
"hide": "隐藏",
"hideShareAudioHelper": "不要再显示",
@@ -319,9 +350,11 @@
"kickParticipantButton": "移除",
"kickParticipantDialog": "你确定你要移除这位参会者吗?",
"kickParticipantTitle": "移除这位参会者?",
"kickSystemTitle": "你已被移出会议",
"kickTitle": "{{participantDisplayName}}将你从会议中移除",
"learnMore": "了解详情",
"linkMeeting": "关联会议",
"linkMeetingTitle": "将会议链接到Salesforce",
"linkMeetingTitle": "议关联到Salesforce",
"liveStreaming": "直播中",
"liveStreamingDisabledBecauseOfActiveRecordingTooltip": "正在录制中,无法开启",
"localUserControls": "本地用户控制",
@@ -377,6 +410,10 @@
"recentlyUsedObjects": "你最近使用的对象",
"recording": "录制中",
"recordingDisabledBecauseOfActiveLiveStreamingTooltip": "直播时无法使用",
"recordingInProgressDescription": "本会议正由AI录制并分析{{learnMore}},你已被静音。如需发言,视为同意录制。",
"recordingInProgressDescriptionFirstHalf": "本会议正由AI录制并分析",
"recordingInProgressDescriptionSecondHalf": ",你已被静音。如需发言,视为同意录制。",
"recordingInProgressTitle": "录制中",
"rejoinNow": "马上重新加入",
"remoteControlAllowedMessage": "{{user}}接受了你的远程控制请求!",
"remoteControlDeniedMessage": "{{user}}拒绝了你的远程控制请求!",
@@ -413,6 +450,7 @@
"sessTerminatedReason": "会议已经结束",
"sessionRestarted": "由于连接问题,呼叫重新启动。",
"shareAudio": "继续",
"shareAudioAltText": "如需分享内容,请切换到“浏览器标签页”,勾选“分享音频”并点击“分享”按钮",
"shareAudioTitle": "如何分享音频",
"shareAudioWarningD1": "你需要在分享你的音频之前停止共享屏幕。",
"shareAudioWarningD2": "你需要重新启动你的共享屏幕并勾选“共享音频”。",
@@ -423,7 +461,10 @@
"shareScreenWarningD2": "你需要停止音频共享后,重新启动你的共享屏幕并勾选“共享音频”。",
"shareScreenWarningH1": "如果你只想共享屏幕:",
"shareScreenWarningTitle": "在共享你的屏幕之前,你需要停止共享音频",
"shareVideoConfirmPlay": "即将打开外部网站,是否继续?",
"shareVideoConfirmPlayTitle": "需先停止音频共享才能分享屏幕",
"shareVideoLinkError": "请提供正确的视频链接。",
"shareVideoLinkStopped": "{{name}}向你分享了一个视频",
"shareVideoTitle": "分享视频",
"shareYourScreen": "共享你的屏幕",
"shareYourScreenDisabled": "共享屏幕已禁用。",
@@ -474,6 +515,10 @@
"viewUpgradeOptions": "查看升级选项",
"viewUpgradeOptionsContent": "要获取无限制的高级功能如录制、转录、RTMP 流等,请升级你的计划。",
"viewUpgradeOptionsTitle": "你发现了高级版功能!",
"whiteboardLimitContent": "同时在线白板人数已达上限,暂时无法继续使用",
"whiteboardLimitReference": "详情请访问",
"whiteboardLimitReferenceUrl": "我们的网站",
"whiteboardLimitTitle": "白板人数受限",
"yourEntireScreen": "你的整个屏幕"
},
"documentSharing": {
@@ -498,6 +543,19 @@
"veryBad": "非常差",
"veryGood": "非常好"
},
"fileSharing": {
"downloadFailedDescription": "请稍后重试",
"downloadFailedTitle": "下载失败",
"downloadFile": "下载",
"dragAndDrop": "拖拽文件到此处上传",
"fileAlreadyUploaded": "文件已上传至本次会议",
"fileTooLargeDescription": "请确保文件不超过 {{ maxFileSize }}",
"fileTooLargeTitle": "文件太大",
"removeFile": "移除",
"uploadFailedDescription": "请稍后重试",
"uploadFailedTitle": "上传失败",
"uploadFile": "文件共享"
},
"filmstrip": {
"accessibilityLabel": {
"heading": "视频缩略图"
@@ -545,13 +603,16 @@
"noNumbers": "无呼入号码。",
"noPassword": "无",
"noRoom": "没有指定要呼入的会议室。",
"noWhiteboard": "无法加载白板",
"numbers": "拨入号码",
"password": "$t(lockRoomPasswordUppercase)",
"reachedLimit": "你已达到套餐限制。",
"sip": "SIP地址",
"sipAudioOnly": "仅限SIP音频地址",
"title": "分享",
"tooltip": "分享此会议的链接和拨入信息",
"upgradeOptions": "请查看升级选项于"
"upgradeOptions": "请查看升级选项于",
"whiteboardError": "加载白板出错,请稍后重试"
},
"inlineDialogFailure": {
"msg": "我们似乎出了点小问题。",
@@ -615,6 +676,7 @@
"on": "直播已开始",
"onBy": "{{name}}已开始直播",
"pending": "直播启动中……",
"policyError": "你尝试开始直播的速度太快,请稍后再试!",
"serviceName": "直播服务",
"sessionAlreadyActive": "本次会议已经在录制或直播",
"signIn": "使用 Google 账号登录",
@@ -630,13 +692,13 @@
"lobby": {
"backToKnockModeButton": "请求加入",
"chat": "聊天",
"dialogTitle": "大厅模式",
"disableDialogContent": "大厅模式已开启,此功能确保未经授权的人士不能加入你的会议,确定要关闭吗?",
"dialogTitle": "等候室模式",
"disableDialogContent": "等候室模式已开启,此功能确保未经授权的人士不能加入你的会议,确定要关闭吗?",
"disableDialogSubmit": "关闭",
"emailField": "请输入你的邮箱",
"enableDialogPasswordField": "设置密码(可选)",
"enableDialogSubmit": "开启",
"enableDialogText": "在大厅模式下,参会者只有被主持人同意后才能加入,进而保护你的会议。",
"enableDialogText": "在等候室模式下,参会者只有被主持人同意后才能加入,进而保护你的会议。",
"enterPasswordButton": "输入会议密码",
"enterPasswordTitle": "输入密码加入会议",
"errorMissingPassword": "加入请求被拒绝。",
@@ -651,18 +713,18 @@
"knockButton": "请求加入",
"knockTitle": "有人想要加入会议",
"knockingParticipantList": "请求加入的参会者名单",
"lobbyChatStartedNotification": "{{moderator}}与{{attendee}}开始在大厅聊天",
"lobbyChatStartedTitle": "{{moderator}}已开始与你进行大厅聊天",
"nameField": "输入你的名字",
"lobbyChatStartedNotification": "{{moderator}}与{{attendee}}开始在等候室聊天",
"lobbyChatStartedTitle": "{{moderator}}已开始与你进行等候室聊天",
"lobbyClosed": "等候室已关闭",
"nameField": "输入你的姓名",
"notificationLobbyAccessDenied": "{{originParticipantName}}已拒绝{{targetParticipantName}}的加入请求",
"notificationLobbyAccessGranted": "{{originParticipantName}}已通过{{targetParticipantName}}的加入请求",
"notificationLobbyDisabled": "{{originParticipantName}}关闭了大厅模式",
"notificationLobbyEnabled": "{{originParticipantName}}开启了大厅模式",
"notificationTitle": "大厅",
"passwordField": "输入会议密码",
"notificationLobbyDisabled": "{{originParticipantName}}关闭了等候室模式",
"notificationLobbyEnabled": "{{originParticipantName}}开启了等候室模式",
"notificationTitle": "等候室",
"passwordJoinButton": "加入",
"title": "大厅",
"toggleLabel": "开启大厅模式"
"title": "等候室",
"toggleLabel": "开启等候室模式"
},
"localRecording": {
"clientState": {
@@ -705,7 +767,9 @@
"me": "我",
"notify": {
"OldElectronAPPTitle": "安全漏洞!",
"allowAction": "允许",
"allowAudio": "允许开启麦克风",
"allowBoth": "允许音视频",
"allowVideo": "允许开启摄像头",
"allowedUnmute": "你可以解除麦克风静音、启动摄像头或共享屏幕。",
"audioUnmuteBlockedDescription": "由于系统限制,麦克风解除静音操作被暂时阻止。",
"audioUnmuteBlockedTitle": "麦克风解除静音被阻止!",
@@ -713,9 +777,14 @@
"connectedOneMember": "{{name}}加入了会议",
"connectedThreePlusMembers": "{{name}}和其他人加入了会议",
"connectedTwoMembers": "{{first}}和{{second}}加入了会议",
"dataChannelClosed": "视频质量受损",
"dataChannelClosedDescription": "桥接通道已断开连接,因此视频质量限制为最低设置。",
"disabledIframe": "嵌入仅用于演示目的,因此此通话将在 {{timeout}} 分钟后断开连接。",
"connectionFailed": "连接失败,请稍后再试",
"dataChannelClosed": "视频质量可能受影响",
"dataChannelClosedDescription": "桥接通道已断开,视频质量可能会被限制为最低设置",
"dataChannelClosedDescriptionWithAudio": "桥接通道已断开,音视频可能会出现卡顿或中断",
"dataChannelClosedWithAudio": "音视频质量可能受影响",
"disabledIframe": "嵌入仅用于演示,本次通话将在{{timeout}}分钟后自动断开",
"disabledIframeSecondaryNative": "嵌入{{domain}}仅用于演示,本次通话将在{{timeout}}分钟后自动断开",
"disabledIframeSecondaryWeb": "嵌入{{domain}}仅用于演示,本次通话将在{{timeout}}分钟后自动断开。如需在正式环境嵌入,请使用<a href='{{jaasDomain}}' rel='noopener noreferrer' target='_blank'>Jitsi服务</a>",
"disconnected": "已断开连接",
"displayNotifications": "显示通知",
"dontRemindMe": "不要提醒我",
@@ -723,7 +792,10 @@
"focusFail": "{{component}}不可用 - {{ms}}秒后重试",
"gifsMenu": "GIPHY",
"groupTitle": "通知",
"hostAskedUnmute": "主持人希望你发言",
"hostAskedUnmute": "主持人你发言",
"invalidTenant": "无效的组织空间",
"invalidTenantHyphenDescription": "你所使用的组织空间名无效(不能以‘-’开头或结尾)",
"invalidTenantLengthDescription": "你所使用的组织空间名过长",
"invitedOneMember": "已邀请{{name}}",
"invitedThreePlusMembers": "已邀请{{name}}以及另外{{count}}人",
"invitedTwoMembers": "已邀请{{first}}和{{second}}",
@@ -760,7 +832,8 @@
"newDeviceAction": "使用",
"newDeviceAudioTitle": "检测到新的音频设备",
"newDeviceCameraTitle": "检测到新的摄像头",
"noiseSuppressionDesktopAudioDescription": "在共享桌面音频时无法启用降噪功能,请禁用并重试。",
"nextToSpeak": "下一个轮到你发言",
"noiseSuppressionDesktopAudioDescription": "在共享桌面音频时无法开启降噪功能,请禁用并重试。",
"noiseSuppressionFailedTitle": "无法启动降噪功能",
"noiseSuppressionStereoDescription": "暂不支持立体声音频降噪。",
"oldElectronClientDescription1": "你似乎正在使用存在已知安全漏洞的旧版Jitsi Meet客户端请确保您更新到我们的",
@@ -784,45 +857,62 @@
"startSilentDescription": "重新加入会议以开启音频",
"startSilentTitle": "你加入时没有开启音频!",
"suboptimalBrowserWarning": "我们担心你本次会议体验欠佳,我们正在寻找改进的方法。与此同时,请尝试使用<a href='{{recommendedBrowserPageLink}}' target='_blank'>完全支持的浏览器</a>。",
"suboptimalExperienceTitle": "浏览器警告",
"suboptimalExperienceTitle": "浏览器提示",
"suggestRecordingAction": "开始录制",
"suggestRecordingDescription": "是否需要录制本次会议?",
"suggestRecordingTitle": "录制会议",
"unmute": "解除静音",
"videoMutedRemotelyDescription": "你随时可以重新打开。",
"unmuteVideo": "开启摄像头",
"videoMutedRemotelyDescription": "你可随时重新开启视频",
"videoMutedRemotelyTitle": "{{participantDisplayName}}已关闭你的视频",
"videoUnmuteBlockedDescription": "由于系统限制,开启摄像头共享屏幕操作已被暂时阻止。",
"videoUnmuteBlockedTitle": "开启摄像头和共享屏幕被阻止",
"viewLobby": "查看大厅",
"waitingParticipants": "{{waitingParticipants}}人"
"videoUnmuteBlockedDescription": "由于系统限制,暂时无法开启摄像头共享桌面",
"videoUnmuteBlockedTitle": "摄像头和桌面共享被禁用",
"viewLobby": "查看等候室",
"viewParticipants": "查看参会者",
"viewVisitors": "查看观众",
"waitingParticipants": "{{waitingParticipants}}人",
"waitingVisitors": "排队等候的观众:{{waitingVisitors}}人",
"waitingVisitorsTitle": "会议尚未开始!",
"whiteboardLimitDescription": "因用户数量已接近上限,白板即将关闭,请及时保存内容。",
"whiteboardLimitTitle": "白板使用限制"
},
"participantsPane": {
"actions": {
"admit": "同意",
"admitAll": "同意全部",
"admit": "同意加入",
"admitAll": "全部同意加入",
"allow": "允许参会者:",
"allowVideo": "允许视频",
"askUnmute": "请求解除静音",
"audioModeration": "自解除静音",
"allowVideo": "允许开启摄像头",
"askUnmute": "请求取消静音",
"audioModeration": "自解除静音",
"blockEveryoneMicCamera": "禁用所有人的麦克风和摄像头",
"breakoutRooms": "分组讨论室",
"goLive": "开始直播",
"invite": "邀请其他人",
"lowerAllHands": "取消全部举手",
"lowerHand": "取消举手",
"moreModerationActions": "更多主持人选项",
"moreModerationControls": "更多主持人控制",
"moreParticipantOptions": "更多参会者选项",
"mute": "静音",
"muteAll": "全体静音",
"muteEveryoneElse": "全体静音",
"muteEveryoneElse": "静音其他人",
"reject": "拒绝",
"stopEveryonesVideo": "禁用所有人视频",
"stopVideo": "禁用视频",
"unblockEveryoneMicCamera": "允许所有人麦克风和摄像头",
"videoModeration": "开启视频"
"stopEveryonesVideo": "关闭所有人摄像头",
"stopVideo": "关闭摄像头",
"unblockEveryoneMicCamera": "允许所有人开启麦克风和摄像头",
"videoModeration": "允许开启视频"
},
"close": "关闭",
"headings": {
"lobby": "大厅({{count}}人)",
"lobby": "等候室({{count}}人)",
"participantsList": "会议参会者({{count}}人)",
"visitors": "访客(({{count}}人)",
"waitingLobby": "在大厅等待{{count}}人)"
"visitorInQueue": "(排队中:{{count}}人)",
"visitorRequests": "请求加入:{{count}}人)",
"visitors": "观众(({{count}}人)",
"waitingLobby": "在等候室等待({{count}}人)"
},
"search": "搜索参会者",
"searchDescription": "输入关键词快速筛选参会者",
"title": "参会者"
},
"passwordDigitsOnly": "最多{{number}}位数字",
@@ -831,20 +921,24 @@
"pinnedParticipant": "参会者已固定",
"polls": {
"answer": {
"edit": "编辑",
"send": "发送",
"skip": "跳过",
"submit": "提交"
},
"by": "由{{ name }}",
"by": "由{{ name }}发起",
"closeButton": "结束投票",
"create": {
"addOption": "添加选项",
"answerPlaceholder": "选项{{index}}",
"cancel": "取消",
"create": "建投票",
"create": "建投票",
"pollOption": "投票选项{{index}}",
"pollQuestion": "投票内容",
"questionPlaceholder": "提出问题",
"removeOption": "除选项",
"send": "发送"
"questionPlaceholder": "请输入你的问题",
"removeOption": "除选项",
"save": "保存",
"send": "发布"
},
"errors": {
"notUniqueOption": "选项必须是唯一的"
@@ -866,61 +960,65 @@
"audioAndVideoError": "音频和视频错误:",
"audioDeviceProblem": "你的音频设备存在问题",
"audioOnlyError": "音频错误:",
"audioTrackError": "无法创建音轨。",
"callMe": "给我打电话",
"callMeAtNumber": "打电话给我,号码",
"calling": "正在拨号",
"audioTrackError": "无法创建音频轨道",
"callMe": "呼叫我",
"callMeAtNumber": "请拨打我的号码:",
"calling": "正在呼叫",
"configuringDevices": "正在配置设备……",
"connectedWithAudioQ": "你已连接音频?",
"connection": {
"failed": "连接测试失败!",
"good": "你的网络连接看起来很好!",
"nonOptimal": "你的网络连接不太理想",
"poor": "你的网络连接不太理想"
"poor": "你的网络连接较差",
"running": "正在测试连接……"
},
"connectionDetails": {
"audioClipping": "我们预计你的音频会有卡顿现象。",
"audioHighQuality": "我们预计你的音频质量会很好。",
"audioLowNoVideo": "我们预计你的音频质量会很差且没有视频画面",
"goodQuality": "太棒了!你的媒体质量会很好。",
"noMediaConnectivity": "我们无法建立连接,通常是防火墙或NAT的问题。",
"noVideo": "我们预计你的视频画会很糟糕",
"undetectable": "如果仍无法在浏览器中进行通话,我们建议你检查扬声器、麦克风和摄像头的设置,确定浏览器是否有使用麦克风和摄像头的权限,并将浏览器升级到最新版本。如果仍未解决问题,请与开发人员联系。",
"veryPoorConnection": "我们预计你的通话质量会非常糟糕。",
"videoFreezing": "我们预计你的视频会冻结、变黑并且呈像素化状态。",
"videoHighQuality": "我们预计你的视频质量会很好。",
"videoLowQuality": "我们预计你的视频帧率和分辨率会很低。",
"videoTearing": "我们预计你的视频会呈现像素化或有视觉伪影。"
"audioClipping": "预计你的音频可能会断断续续",
"audioHighQuality": "你的音频质量预计会很清晰",
"audioLowNoVideo": "预计你的音频质量较差且无法显示视频画面",
"goodQuality": "网络和设备状态都很好,体验会很流畅",
"noMediaConnectivity": "无法建立音视频连接通常是防火墙或NAT设置导致",
"noVideo": "预计你的视频画面可能会很糟糕",
"testFailed": "连接测试遇到异常,但不一定会影响实际体验",
"undetectable": "如果你仍无法正常通话,请检查扬声器、麦克风、摄像头设置,并确保浏览器有相关权限且为最新版本。如仍有问题,建议联系技术支持。",
"veryPoorConnection": "你的通话质量可能会非常差",
"videoFreezing": "预计你的视频会出现卡顿、黑屏或马赛克",
"videoHighQuality": "你的视频质量预计会很清晰",
"videoLowQuality": "预计你的视频帧率和分辨率会很低或画面不流畅",
"videoTearing": "预计你的视频画面会有马赛克或其它异常现象"
},
"copyAndShare": "复制并分享会议链接",
"dialInMeeting": "拨打会议电话",
"dialInPin": "拨打会议电话并输入PIN码",
"dialInMeeting": "拨电话接入会议",
"dialInPin": "电话接入会议并输入PIN码",
"dialing": "正在拨号",
"doNotShow": "不再显示",
"doNotShow": "不再显示此页面",
"errorDialOut": "无法拨出",
"errorDialOutDisconnected": "无法拨出,已断开连接",
"errorDialOutFailed": "无法拨出,呼叫失败",
"errorDialOutStatus": "获取拨出状态出错",
"errorMissingName": "请输入你的名字以加入会议",
"errorNoPermissions": "你需要允许访问麦克风和摄像头",
"errorStatusCode": "拨出错误,状态码:{{status}}",
"errorValidation": "号码验失败",
"iWantToDialIn": "我想拨打电话加入",
"initiated": "通话已发起",
"errorDialOutFailed": "无法拨出,通话失败",
"errorDialOutStatus": "获取拨出状态出错",
"errorMissingName": "请输入姓名后再加入会议",
"errorNoPermissions": "请开启麦克风和摄像头权限",
"errorStatusCode": "拨号失败,状态码:{{status}}",
"errorValidation": "号码验失败",
"iWantToDialIn": "我想通过电话加入",
"initiated": "呼叫已发起",
"joinAudioByPhone": "使用电话音频加入",
"joinMeeting": "加入会议",
"joinMeetingInLowBandwidthMode": "以省流模式加入",
"joinWithoutAudio": "无音频加入",
"keyboardShortcuts": "开启键盘快捷键",
"keyboardShortcuts": "开启快捷键",
"linkCopied": "链接已复制到剪贴板",
"lookGood": "你的麦克风工作正常",
"lookGood": "设备一切正常",
"or": "或",
"premeeting": "会前",
"premeeting": "会前设置",
"proceedAnyway": "仍然继续",
"screenSharingError": "共享屏幕错误:",
"startWithPhone": "以电话音频开始",
"unsafeRoomConsent": "我了解风险,我想加入会议",
"videoOnlyError": "视频错误:",
"videoTrackError": "无法创建视频轨道。",
"recordingWarning": "其他参会者可能正在录制本次会议",
"screenSharingError": "屏幕共享出错:",
"startWithPhone": "用电话音频开始",
"unsafeRoomConsent": "我了解风险,仍然加入会议",
"videoOnlyError": "视频出错:",
"videoTrackError": "无法创建视频轨道",
"viewAllNumbers": "查看所有号码"
},
"presenceStatus": {
@@ -981,7 +1079,6 @@
"limitNotificationDescriptionNative": "由于高需求,您的录制将限制在{{limit}}分钟内。若要无限制录制,请尝试<3>{{app}}</3>。",
"limitNotificationDescriptionWeb": "由于高需求,您的录制将限制在{{limit}}分钟内。若要无限制录制,请尝试<a href={{url}} rel='noopener noreferrer' target='_blank'>{{app}}</a>。",
"linkGenerated": "我们已生成录制链接。",
"live": "直播中",
"localRecordingNoNotificationWarning": "系统不会主动通知参会者录制已开启,主持人需另行提醒。",
"localRecordingNoVideo": "视频未被录制",
"localRecordingStartWarning": "请确保在退出会议之前停止录制,以便保存录制内容。",
@@ -998,13 +1095,16 @@
"onBy": "{{name}}开启了录制",
"onlyRecordSelf": "仅录制我的音频和视频",
"pending": "正在准备录制会议……",
"rec": "录制中",
"policyError": "",
"recordAudioAndVideo": "",
"recordTranscription": "",
"saveLocalRecording": "本地保存录制文件(测试版)",
"serviceDescription": "录制将由录制服务保存",
"serviceDescriptionCloud": "云录制",
"serviceDescriptionCloudInfo": "录制的会议将在录制后的24小时内自动清除。",
"serviceName": "录制服务",
"sessionAlreadyActive": "此会议已经在录制或直播中",
"showAdvancedOptions": "高级选项",
"signIn": "登录",
"signOut": "注销",
"surfaceError": "请选择当前选项卡。",
@@ -1033,21 +1133,23 @@
"audio": "音频",
"buttonLabel": "设置",
"calendar": {
"about": "{{appName}}日历集成用于安全访问你的日历,读取即将到来的活动。",
"about": "{{appName}}日历集成安全访问你的日历,读取即将开始的会议。",
"disconnect": "断开连接",
"microsoftSignIn": "使用 Microsoft 登录",
"signedIn": "目前正在读取{{email}}的日历事件,点击下面的断开连接可以停止读取日历事件。",
"signedIn": "正在读取{{email}}的日历事件,点击下方“断开连接”可停止同步日历。",
"title": "日历"
},
"chatWithPermissions": "聊天需要相关权限",
"desktopShareFramerate": "共享屏幕帧率",
"desktopShareHighFpsWarning": "高帧率的共享屏幕的可能会影响你的网速,你需要重新启动共享屏幕以使新设置生效。",
"desktopShareWarning": "你需要重新启动共享屏幕以使新设置生效。",
"devices": "设备",
"followMe": "所有人跟随",
"framesPerSecond": "",
"incomingMessage": "新消息",
"followMe": "全员视角跟随",
"followMeRecorder": "录制画面跟随我",
"framesPerSecond": "帧率FPS",
"incomingMessage": "新消息提醒",
"language": "语言",
"loggedIn": "{{name}}登录",
"loggedIn": "已登录:{{name}}",
"maxStageParticipants": "可以固定的最大参会者人数",
"microphones": "麦克风",
"moderator": "主持人",
@@ -1057,52 +1159,55 @@
"noDevice": "无",
"notifications": "通知",
"participantJoined": "参会者已加入",
"participantKnocking": "参会者已进入大厅",
"participantKnocking": "参会者已进入等候室",
"participantLeft": "参会者已离开",
"playSounds": "播放提示音",
"reactions": "会议反应",
"sameAsSystem": "与系统相同{{label}}",
"selectAudioOutput": "音频输出",
"reactions": "会议互动表情",
"sameAsSystem": "与系统一致{{label}}",
"selectAudioOutput": "音频输出设备",
"selectCamera": "摄像头",
"selectMic": "麦克风",
"selfView": "本人视图",
"shortcuts": "快捷键",
"showSubtitlesOnStage": "主画面显示字幕",
"speakers": "扬声器",
"startAudioMuted": "所有人开始时静音",
"startReactionsMuted": "关闭所有人反应提示音",
"startVideoMuted": "所有人开始时隐藏视频画面",
"talkWhileMuted": "通话时静音",
"startAudioMuted": "所有人加入时静音",
"startReactionsMuted": "关闭所有人互动音效",
"startVideoMuted": "所有人加入时隐藏视频",
"talkWhileMuted": "静音时说话提醒",
"title": "设置",
"video": "视频"
},
"settingsView": {
"advanced": "高级",
"advanced": "高级设置",
"alertCancel": "取消",
"alertOk": "确认",
"alertTitle": "警告",
"alertURLText": "输入的服务器址无效",
"buildInfoSection": "生成信息",
"conferenceSection": "会议",
"disableCallIntegration": "禁用本地电话",
"disableCrashReporting": "禁用崩溃报告",
"disableCrashReportingWarning": "你确定要禁用崩溃报告吗?重启应用后生效。",
"disableP2P": "禁用点对点模式",
"alertURLText": "输入的服务器址无效",
"apply": "应用",
"buildInfoSection": "构建信息",
"conferenceSection": "会议设置",
"disableCallIntegration": "禁用系统通话集成",
"disableCrashReporting": "关闭崩溃报告",
"disableCrashReportingWarning": "确定要关闭崩溃报告吗?重启应用后设置才会生效",
"disableP2P": "关闭点对点模式",
"displayName": "显示名称",
"displayNamePlaceholderText": "例如:张三",
"email": "邮箱",
"emailPlaceholderText": "email@example.com",
"gavatarMessage": "如果你的邮箱已绑定Gravatar头像我们将自动为你显示头像",
"goTo": "前往",
"header": "设置",
"help": "帮助",
"links": "链接",
"links": "相关链接",
"privacy": "隐私",
"profileSection": "简介",
"profileSection": "个人信息",
"sdkVersion": "SDK版本",
"serverURL": "服务器址",
"serverURL": "服务器址",
"showAdvanced": "显示高级设置",
"startCarModeInLowBandwidthMode": "同时开启驾驶模式和省流模式",
"startWithAudioMuted": "关闭音频并启动",
"startWithVideoMuted": "关闭视频并启动",
"startCarModeInLowBandwidthMode": "省流模式下开启驾驶模式",
"startWithAudioMuted": "进入会议时音频静音",
"startWithVideoMuted": "进入会议时关闭视频",
"terms": "条款",
"version": "APP版本"
},
@@ -1113,20 +1218,22 @@
"speaker": "扬声器",
"speakerStats": {
"angry": "生气",
"disgusted": "呕吐",
"disgusted": "嫌弃",
"displayEmotions": "显示表情",
"fearful": "害怕",
"happy": "笑脸",
"happy": "开心",
"hours": "{{count}}时",
"labelTooltip": "参会者人数:{{count}}",
"minutes": "{{count}}分",
"name": "名字",
"neutral": "中",
"sad": "悲伤",
"neutral": "中",
"sad": "难过",
"search": "搜索",
"searchDescription": "输入关键词筛选参会者",
"searchHint": "搜索参会者",
"seconds": "{{count}}秒",
"speakerStats": "发言统计",
"speakerTime": "发言时",
"speakerStats": "参会者统计",
"speakerTime": "发言时",
"surprised": "惊讶"
},
"startupoverlay": {
@@ -1144,36 +1251,37 @@
},
"toggleTopPanelLabel": "切换顶部面板",
"toolbar": {
"Settings": "设置",
"Settings": "打开设置",
"accessibilityLabel": {
"Settings": "切换设置",
"audioOnly": "切换仅音频模式",
"audioRoute": "选择音频设备",
"boo": "嘘声",
"breakoutRoom": "加入/离开分组讨论室",
"callQuality": "管理视频质量",
"breakoutRooms": "分组讨论室",
"callQuality": "调整视频质量",
"carmode": "驾驶模式",
"cc": "切换字幕",
"chat": "打开/关闭聊天",
"clap": "鼓掌",
"closeChat": "关闭聊天",
"closeChat": "关闭聊天窗口",
"closeMoreActions": "关闭更多操作菜单",
"closeParticipantsPane": "关闭参会者面板",
"collapse": "折叠",
"closedCaptions": "隐藏字幕",
"collapse": "收起",
"document": "切换共享文档",
"documentClose": "关闭共享文档",
"documentOpen": "打开共享文档",
"download": "下载我们的APP",
"embedMeeting": "嵌入会议",
"endConference": "结束会议",
"endConference": "结束全体会议",
"enterFullScreen": "进入全屏模式",
"enterTileView": "进入平铺视图",
"enterTileView": "进入画廊视图",
"exitFullScreen": "退出全屏模式",
"exitTileView": "退出平铺视图",
"exitTileView": "退出画廊视图",
"expand": "展开",
"feedback": "提供反馈",
"fullScreen": "切换全屏模式",
"giphy": "切换GIPHY菜单",
"giphy": "切换表情动图菜单",
"grantModerator": "授予主持人权限",
"hangup": "离开会议",
"heading": "工具栏",
@@ -1183,30 +1291,32 @@
"kick": "移除参会者",
"laugh": "大笑",
"leaveConference": "离开会议",
"like": "竖起大拇指",
"linkToSalesforce": "链接到 Salesforce",
"lobbyButton": "开启/关闭大厅模式",
"localRecording": "切换本地录制控件",
"lockRoom": "开启/关闭会议密码",
"lowerHand": "放下手",
"like": "点赞",
"linkToSalesforce": "关联到Salesforce",
"lobbyButton": "开启/关闭等候室",
"localRecording": "切换本地录制",
"lockRoom": "设置/取消会议密码",
"love": "爱心",
"lowerHand": "取消举手",
"moreActions": "更多操作",
"moreActionsMenu": "更多操作菜单",
"moreOptions": "显示更多选项",
"mute": "静音",
"mute": "麦克风静音",
"muteEveryone": "将所有人静音",
"muteEveryoneElse": "将其他人静音",
"muteEveryoneElsesVideoStream": "停止其他人的视频",
"muteEveryonesVideoStream": "停止全部人的视频",
"muteGUMPending": "连接你的麦克风",
"noiseSuppression": "降噪",
"openChat": "打开聊天",
"participants": "打开参会者面板",
"muteEveryoneElse": "除自己外全部静音",
"muteEveryoneElsesVideoStream": "停止其他人摄像头",
"muteEveryonesVideoStream": "关闭所有人摄像头",
"muteGUMPending": "正在连接麦克风",
"noiseSuppression": "开启降噪(测试版)",
"openChat": "打开聊天窗口",
"participants": "打开参会者面板,参会者(共{{participantsCount}}人)",
"pip": "切换画中画模式",
"privateMessage": "发送私人消息",
"profile": "编辑你的个人资料",
"privateMessage": "发送私",
"profile": "编辑个人信息",
"raiseHand": "举手",
"reactions": "反应",
"reactionsMenu": "反应菜单",
"react": "消息表情",
"reactions": "互动表情",
"reactionsMenu": "互动表情菜单",
"recording": "切换录制",
"remoteMute": "静音参会者",
"remoteVideoMute": "禁用参会者摄像头",
@@ -1223,45 +1333,46 @@
"silence": "沉默",
"speakerStats": "切换参会者统计",
"stopScreenSharing": "停止屏幕共享",
"stopSharedVideo": "停止视频",
"stopSharedVideo": "停止视频共享",
"surprised": "惊讶",
"tileView": "切换画廊视图",
"toggleCamera": "切换摄像头",
"toggleFilmstrip": "切换幻灯片",
"unmute": "取消静音",
"videoblur": "切换视频模糊",
"toggleFilmstrip": "切换缩略图栏",
"unmute": "取消麦克风静音",
"videoblur": "切换虚化背景",
"videomute": "关闭摄像头",
"videomuteGUMPending": "连接你的摄像头",
"videounmute": "启动摄像头"
"videomuteGUMPending": "正在连接摄像头",
"videounmute": "打开摄像头"
},
"addPeople": "添加员到你的通话中",
"addPeople": "添加员到通话中",
"audioOnlyOff": "关闭省流模式",
"audioOnlyOn": "启省流模式",
"audioOnlyOn": "启省流模式",
"audioRoute": "选择音频设备",
"audioSettings": "音频设置",
"authenticate": "证",
"authenticate": "身份验证",
"boo": "嘘声",
"callQuality": "管理视频质量",
"callQuality": "调整视频质量",
"chat": "打开/关闭聊天",
"clap": "鼓掌",
"closeChat": "关闭聊天",
"closeParticipantsPane": "关闭参会者面板",
"closeReactionsMenu": "关闭反应菜单",
"disableNoiseSuppression": "关闭降噪",
"disableReactionSounds": "你可以禁用此会议的反应声音",
"closeParticipantsPane": "关闭参会者列表",
"closeReactionsMenu": "关闭互动表情菜单",
"closedCaptions": "关闭字幕",
"disableNoiseSuppression": "关闭降噪功能(测试版)",
"disableReactionSounds": "你可以禁用此会议的互动音效",
"documentClose": "关闭文件共享",
"documentOpen": "开启文件共享",
"download": "下载我们的APP",
"e2ee": "端到端加密",
"embedMeeting": "嵌入会议",
"enableNoiseSuppression": "启用降噪",
"endConference": "结束会议",
"enableNoiseSuppression": "开启降噪功能(测试版)",
"endConference": "结束全体会议",
"enterFullScreen": "进入全屏模式",
"enterTileView": "进入画廊视图",
"exitFullScreen": "退出全屏模式",
"exitTileView": "退出画廊视图",
"feedback": "提供反馈",
"giphy": "关闭GIPHY菜单",
"feedback": "意见反馈",
"giphy": "表情动画菜单",
"hangup": "离开会议",
"help": "帮助",
"hideWhiteboard": "隐藏白板",
@@ -1270,13 +1381,14 @@
"laugh": "大笑",
"leaveBreakoutRoom": "离开分组讨论室",
"leaveConference": "离开会议",
"like": "竖起大拇指",
"like": "点赞",
"linkToSalesforce": "链接到 Salesforce",
"lobbyButtonDisable": "关闭大厅模式",
"lobbyButtonEnable": "开启大厅模式",
"lobbyButtonDisable": "关闭等候室模式",
"lobbyButtonEnable": "开启等候室模式",
"login": "登录",
"logout": "注销",
"lowerYourHand": "放下手",
"love": "爱心",
"lowerYourHand": "取消举手",
"moreActions": "更多操作",
"moreOptions": "显示更多选项",
"mute": "静音",
@@ -1288,24 +1400,25 @@
"noAudioSignalDialInDesc": "你还可以拨打以下号码加入会议:",
"noAudioSignalDialInLinkDesc": "拨打电话号码",
"noAudioSignalTitle": "你的麦克风没有声音!",
"noiseSuppression": "降噪",
"noisyAudioInputDesc": "听起来你的麦克风在发出噪音,请考虑将其静音或更换设备。",
"noiseSuppression": "降噪(测试版)",
"noisyAudioInputDesc": "检测到你的麦克风有杂音,请考虑静音或更换设备。",
"noisyAudioInputTitle": "你的麦克风似乎很嘈杂!",
"openChat": "打开聊天",
"openReactionsMenu": "打开反应菜单",
"openReactionsMenu": "打开互动表情菜单",
"participants": "参会者",
"pip": "进入画中画模式",
"privateMessage": "发送私人消息",
"privateMessage": "发送私",
"profile": "编辑你的个人资料",
"raiseHand": "举手",
"raiseYourHand": "举手",
"reactionBoo": "发送嘘声反应",
"reactionClap": "发送鼓掌反应",
"reactionLaugh": "发送大笑反应",
"reactionLike": "发送竖起大拇指反应",
"reactionSilence": "发送沉默反应",
"reactionSurprised": "发送惊讶反应",
"reactions": "反应",
"reactionBoo": "发送嘘声",
"reactionClap": "发送鼓掌",
"reactionHeart": "发送爱心",
"reactionLaugh": "发送大笑",
"reactionLike": "发送点赞",
"reactionSilence": "发送沉默",
"reactionSurprised": "发送惊讶",
"reactions": "互动表情",
"security": "安全选项",
"selectBackground": "选择背景",
"shareRoom": "邀请他人",
@@ -1333,19 +1446,20 @@
},
"transcribing": {
"ccButtonTooltip": "开启/关闭字幕",
"error": "转录失败,请重试。",
"expandedLabel": "转录已开启",
"failedToStart": "开启转录失败",
"labelToolTip": "会议正在转录",
"off": "转录已停止",
"pending": "准备转录会议中……",
"failed": "转录失败",
"labelTooltip": "本次会议正在进行转录",
"labelTooltipExtra": "会后将提供转录文本",
"openClosedCaptions": "打开字幕",
"original": "原文",
"sourceLanguageDesc": "当前会议语言设置为<b>{{sourceLanguage}}</b><br/>你可以在这里",
"sourceLanguageHere": "更改",
"start": "开启显示字幕",
"stop": "停止显示字幕",
"subtitles": "字幕",
"subtitlesOff": "关",
"tr": "转录"
"subtitlesOff": "关",
"tr": "转录",
"translateTo": "翻译为"
},
"unpinParticipant": "{{participantName}} - 取消固定",
"userMedia": {
@@ -1385,15 +1499,16 @@
},
"videothumbnail": {
"connectionInfo": "连接信息",
"demote": "设为观众",
"domute": "静音",
"domuteOthers": "静音所有人",
"domuteOthers": "静音其他人",
"domuteVideo": "关闭摄像头",
"domuteVideoOfOthers": "关闭所有人的摄像头",
"flip": "翻转",
"domuteVideoOfOthers": "关闭其他人摄像头",
"flip": "翻转画面",
"grantModerator": "授予主持人权限",
"hideSelfView": "隐藏本人视图",
"kick": "移",
"mirrorVideo": "镜像我的视频",
"kick": "移出会议",
"mirrorVideo": "镜像我的画面",
"moderator": "主持人",
"mute": "参会者已被静音",
"muted": "已静音",
@@ -1436,12 +1551,23 @@
"webAssemblyWarningDescription": "此浏览器禁用或不支持WebAssembly"
},
"visitors": {
"chatIndicator": "访客",
"labelTooltip": "访客人数:{{count}}",
"chatIndicator": "观众",
"joinMeeting": {
"description": "你目前是本次会议的观众",
"raiseHand": "举手申请发言",
"title": "正在加入会议",
"wishToSpeak": "如需发言,请点击下方举手,等待主持人同意"
},
"labelTooltip": "观众人数:{{count}}",
"notification": {
"description": "要参与,请举手",
"title": "你是会议中的访客"
}
"demoteDescription": "你已被{{actor}}切换为观众身份,如需参与发言请举手申请",
"noMainParticipantsDescription": "暂无参会者开启会议,请稍后再试",
"noMainParticipantsTitle": "会议尚未开始",
"noVisitorLobby": "当前会议已开启等候室,暂无法加入",
"notAllowedPromotion": "需由会议成员同意才能参与讨论",
"title": "你当前为会议观众"
},
"waitingMessage": "会议开始后将自动加入"
},
"volumeSlider": "音量滑块",
"welcomepage": {
@@ -1499,6 +1625,7 @@
"whiteboard": {
"accessibilityLabel": {
"heading": "白板"
}
},
"screenTitle": "白板"
}
}

View File

@@ -109,6 +109,7 @@
}
},
"chat": {
"disabled": "聊天訊息已停用",
"enter": "加入聊天室",
"error": "錯誤:您的訊息未被傳送。原因:{{error}}",
"fieldPlaceHolder": "在此輸入您的訊息",
@@ -122,17 +123,26 @@
"nickname": {
"popover": "選擇名稱",
"title": "輸入名稱來使用聊天",
"titleWithPolls": "輸入名稱使用聊天與投票"
"titleWithCC": "輸入名稱使用聊天與即時字幕",
"titleWithPolls": "輸入名稱來使用聊天與投票",
"titleWithPollsAndCC": "輸入名稱以使用聊天、投票及即時字幕",
"titleWithPollsAndCCAndFileSharing": "輸入名稱以使用聊天、投票、即時字幕及檔案分享"
},
"noMessagesMessage": "此會議尚無訊息,在此開始對話聊天!",
"privateNotice": "傳送私人訊息至 {{recipient}}",
"sendButton": "傳送",
"smileysPanel": "Emoji 面板",
"systemDisplayName": "系統",
"tabs": {
"chat": "聊天",
"closedCaptions": "即時字幕",
"fileSharing": "檔案",
"polls": "投票"
},
"title": "聊天",
"titleWithCC": "即時字幕",
"titleWithFeatures": "聊天與",
"titleWithFileSharing": "檔案",
"titleWithPolls": "聊天與投票",
"you": "您"
},
@@ -143,6 +153,10 @@
"dontShowAgain": "不要再問了",
"installExtensionText": "安裝適用於 Google 行事曆及 Office 365 整合的擴充功能"
},
"closedCaptionsTab": {
"emptyState": "即時字幕內容將在主持人啟用後顯示",
"startClosedCaptionsButton": "啟動即時字幕"
},
"connectingOverlay": {
"joiningRoom": "正在將您連接至您的會議…"
},
@@ -219,7 +233,9 @@
"joinInBrowser": "在瀏覽器中加入",
"launchMeetingLabel": "您想如何加入此會議?",
"launchWebButton": "在瀏覽器開啟",
"noDesktopApp": "您尚未安裝桌面應用程式?",
"noMobileApp": "您尚未安裝該應用程式?",
"or": "或",
"termsAndConditions": "繼續操作即表示您同意我們的<a href='{{termsAndConditionsLink}}' rel='noopener noreferrer' target='_blank'>條款與條件。</a>",
"title": "正在 {{app}} 開啟您的會議…",
"titleNew": "正在開啟您的會議…",
@@ -260,7 +276,10 @@
"Remove": "移除",
"Share": "分享",
"Submit": "送出",
"Understand": "我了解,暫時保持靜音",
"UnderstandAndUnmute": "我了解,請為我解除靜音",
"WaitForHostMsg": "此會議尚未開始,如果您是會議主持人,請進行認證並以主持人身分開始會議。",
"WaitForHostNoAuthMsg": "此會議尚未開始,目前沒有主持人加入,請稍候。",
"WaitingForHostButton": "等待主持人",
"WaitingForHostTitle": "正在等候主持人加入…",
"Yes": "是",
@@ -295,6 +314,7 @@
"conferenceReloadMsg": "我們正試著修復狀況,將在 {{seconds}} 秒後重新連接…",
"conferenceReloadTitle": "喔哦!好像有東西壞掉囉。",
"confirm": "確認",
"confirmBack": "返回",
"confirmNo": "否",
"confirmYes": "是",
"connectError": "喔哦!發生錯誤,無法連接至會議。",
@@ -303,6 +323,8 @@
"contactSupport": "聯絡支援",
"copied": "已複製",
"copy": "複製",
"demoteParticipantDialog": "您確定要將此與會者轉為僅檢視模式嗎?",
"demoteParticipantTitle": "切換為檢視者",
"dismiss": "取消",
"displayNameRequired": "嗨!請問大名?",
"done": "完成",
@@ -314,6 +336,7 @@
"embedMeeting": "嵌入會議",
"enterDisplayName": "請在此輸入您自己的名字",
"error": "錯誤",
"errorRoomCreationRestriction": "您加入速度過快,請稍後再試。",
"gracefulShutdown": "服務目前正在維護中,請稍後再試。",
"grantModeratorDialog": "您確定要授予 {{participantName}} 主持人權限嗎?",
"grantModeratorTitle": "授予主持人權限",
@@ -327,7 +350,9 @@
"kickParticipantButton": "移除",
"kickParticipantDialog": "您確定要將這位與會者移除嗎?",
"kickParticipantTitle": "移除這位與會者?",
"kickSystemTitle": "您已被移出會議",
"kickTitle": "噢!{{participantDisplayName}} 將您從會議中移除",
"learnMore": "了解詳情",
"linkMeeting": "連結會議",
"linkMeetingTitle": "將會議連結至 Salesforce",
"liveStreaming": "直播串流中",
@@ -385,6 +410,10 @@
"recentlyUsedObjects": "您最近使用過的物件",
"recording": "錄製中",
"recordingDisabledBecauseOfActiveLiveStreamingTooltip": "正在直播時無法使用",
"recordingInProgressDescription": "本會議正在錄製並由 AI 分析{{learnMore}},您的音訊與影像已靜音。如果您選擇取消靜音,即表示您同意被錄製。",
"recordingInProgressDescriptionFirstHalf": "本會議正在錄製並由 AI 分析",
"recordingInProgressDescriptionSecondHalf": ",您的音訊與影像已靜音。如果您選擇取消靜音,即表示您同意被錄製。",
"recordingInProgressTitle": "正在錄製",
"rejoinNow": "立即重新加入",
"remoteControlAllowedMessage": "{{user}} 接受您進行遠端控制的請求!",
"remoteControlDeniedMessage": "{{user}} 拒絕您進行遠端控制的請求!",
@@ -421,6 +450,7 @@
"sessTerminatedReason": "會議已經終止",
"sessionRestarted": "通話因連線問題重新啟動。",
"shareAudio": "繼續",
"shareAudioAltText": "如需分享內容,請選擇「瀏覽器分頁」,勾選「分享音訊」後再按「分享」",
"shareAudioTitle": "如何分享音訊",
"shareAudioWarningD1": "您必須先停用分享螢幕才能分享音訊。",
"shareAudioWarningD2": "您必須重新啟動螢幕分享並勾選「分享音訊」選項。",
@@ -431,7 +461,10 @@
"shareScreenWarningD2": "您必須先停用分享音訊後,重新啟動螢幕分享並勾選「分享音訊」選項。",
"shareScreenWarningH1": "如果您只要分享螢幕:",
"shareScreenWarningTitle": "您必須先停用分享音訊才能分享螢幕",
"shareVideoConfirmPlay": "您即將開啟外部網站,是否繼續?",
"shareVideoConfirmPlayTitle": "{{name}} 與您分享了一段影片。",
"shareVideoLinkError": "請提供正確的影片網址。",
"shareVideoLinkStopped": "{{name}} 的影片已停止播放",
"shareVideoTitle": "分享影像",
"shareYourScreen": "分享您的螢幕",
"shareYourScreenDisabled": "螢幕分享已停用。",
@@ -510,6 +543,19 @@
"veryBad": "極差",
"veryGood": "極好"
},
"fileSharing": {
"downloadFailedDescription": "請重試",
"downloadFailedTitle": "下載失敗",
"downloadFile": "下載",
"dragAndDrop": "將檔案拖曳至此或畫面任一處上傳",
"fileAlreadyUploaded": "檔案已上傳至此會議",
"fileTooLargeDescription": "請確認檔案未超過 {{ maxFileSize }}",
"fileTooLargeTitle": "檔案過大",
"removeFile": "移除",
"uploadFailedDescription": "請重試",
"uploadFailedTitle": "上傳失敗",
"uploadFile": "分享檔案"
},
"filmstrip": {
"accessibilityLabel": {
"heading": "視頻縮略圖"
@@ -557,6 +603,7 @@
"noNumbers": "無撥入號碼。",
"noPassword": "無",
"noRoom": "沒有會議室指定要撥入。",
"noWhiteboard": "無法載入白板",
"numbers": "撥入號碼",
"password": "$t(lockRoomPasswordUppercase)",
"reachedLimit": "您已達到您的方案上限",
@@ -564,7 +611,8 @@
"sipAudioOnly": "SIP 僅音訊位址",
"title": "分享",
"tooltip": "顯示此會議的連結及電話撥入號碼",
"upgradeOptions": "請查看升級選項於"
"upgradeOptions": "請查看升級選項於",
"whiteboardError": "載入白板時發生錯誤,請稍後再試。"
},
"inlineDialogFailure": {
"msg": "好像有點卡卡不順。",
@@ -628,6 +676,7 @@
"on": "直播串流已啟動",
"onBy": "{{name}} 啟動了直播串流",
"pending": "啟動直播串流…",
"policyError": "您啟動直播的過快,請稍後重試!",
"serviceName": "直播串流服務",
"sessionAlreadyActive": "已在錄製或直播此工作階段。",
"signIn": "使用 Google 帳號登入",
@@ -666,13 +715,13 @@
"knockingParticipantList": "請求加入的與會者名單",
"lobbyChatStartedNotification": "{{moderator}} 與 {{attendee}} 開始在大廳中聊天",
"lobbyChatStartedTitle": "{{moderator}} 與您開始在大廳中聊天。",
"lobbyClosed": "大廳已關閉",
"nameField": "輸入您的名字",
"notificationLobbyAccessDenied": "{{originParticipantName}} 拒絕了 {{targetParticipantName}} 的加入請求",
"notificationLobbyAccessGranted": "{{originParticipantName}} 同意了 {{targetParticipantName}} 的加入請求",
"notificationLobbyDisabled": "{{originParticipantName}} 已停用大廳模式",
"notificationLobbyEnabled": "{{originParticipantName}} 已啟用大廳模式",
"notificationTitle": "大廳",
"passwordField": "輸入會議密碼",
"passwordJoinButton": "加入",
"title": "大廳",
"toggleLabel": "啟用大廳模式"
@@ -718,7 +767,9 @@
"me": "我",
"notify": {
"OldElectronAPPTitle": "安全漏洞!",
"allowAction": "允許",
"allowAudio": "允許音訊",
"allowBoth": "允許音訊與視訊",
"allowVideo": "允許視訊",
"allowedUnmute": "您可以將麥克風解除靜音、開啟視訊,或是分享您的螢幕。",
"audioUnmuteBlockedDescription": "麥克風解除靜音操作由於系統限制而被暫時封鎖。",
"audioUnmuteBlockedTitle": "麥克風解除靜音遭封鎖!",
@@ -726,10 +777,14 @@
"connectedOneMember": "{{name}} 加入了會議",
"connectedThreePlusMembers": "{{name}} 與其他人加入了會議",
"connectedTwoMembers": "{{first}} 與{{second}} 加入了會議",
"connectionFailed": "連線失敗,請稍後重試!",
"dataChannelClosed": "視訊品質受限",
"dataChannelClosedDescription": "橋接通道已斷開,視訊品質降至最低設定。",
"dataChannelClosedDescriptionWithAudio": "橋接通道已斷開,音訊和視訊可能會受到影響。",
"dataChannelClosedWithAudio": "音訊和視訊品質可能會降低。",
"disabledIframe": "嵌入僅供示範使用,此通話將於 {{timeout}} 分鐘後中斷連線。",
"disabledIframeSecondary": "嵌 {{domain}} 僅為展示用途,此通話將 {{timeout}} 分鐘後中斷連線。請使用在正式環境使用 <a href='{{jaasDomain}}' rel='noopener noreferrer' target='_blank'>Jitsi 服務</a>來內嵌!",
"disabledIframeSecondaryNative": "嵌 {{domain}} 僅供示範,此通話將 {{timeout}} 分鐘後中斷",
"disabledIframeSecondaryWeb": "嵌入 {{domain}} 僅供示範,此通話將於 {{timeout}} 分鐘後中斷,請使用 <a href='{{jaasDomain}}' rel='noopener noreferrer' target='_blank'>Jitsi 服務</a> 來進行正式嵌入!",
"disconnected": "已經中斷連接",
"displayNotifications": "顯示通知給",
"dontRemindMe": "不要再提醒我",
@@ -738,6 +793,9 @@
"gifsMenu": "GIPHY",
"groupTitle": "通知",
"hostAskedUnmute": "主持人希望您發言",
"invalidTenant": "無效的租用者名稱",
"invalidTenantHyphenDescription": "您使用的租用者名稱無效(開頭或結尾不可為「-」)。",
"invalidTenantLengthDescription": "您使用的租用者名稱過長。",
"invitedOneMember": "{{name}} 已受邀請",
"invitedThreePlusMembers": "{{name}} 與 {{count}} 位人員已受邀請",
"invitedTwoMembers": "{{first}} 與 {{second}} 已受邀請",
@@ -774,6 +832,7 @@
"newDeviceAction": "使用",
"newDeviceAudioTitle": "偵測到新的音效裝置",
"newDeviceCameraTitle": "偵測到新的網路攝影機",
"nextToSpeak": "下一位輪到您發言",
"noiseSuppressionDesktopAudioDescription": "分享電腦音訊時無法啟用雜訊抑制,請停用後重試。",
"noiseSuppressionFailedTitle": "啟用雜訊抑制失敗",
"noiseSuppressionStereoDescription": "目前不支援立體聲降噪功能。",
@@ -799,13 +858,21 @@
"startSilentTitle": "您以無音訊輸出方式加入了會議!",
"suboptimalBrowserWarning": "我們恐怕您本次會議體驗不佳,我們會努力改善。在此期間,請嘗試使用<a href='{{recommendedBrowserPageLink}}' target='_blank'>支援的瀏覽器</a> 。",
"suboptimalExperienceTitle": "瀏覽器警告",
"suggestRecordingAction": "開始",
"suggestRecordingDescription": "是否要開始錄製這場會議?",
"suggestRecordingTitle": "錄製此會議",
"unmute": "取消靜音",
"unmuteVideo": "啟用視訊",
"videoMutedRemotelyDescription": "您隨時可以再次啟用。",
"videoMutedRemotelyTitle": "您的視訊已被 {{participantDisplayName}} 停用",
"videoUnmuteBlockedDescription": "啟用網路攝影機與分享螢幕由於系統限制而被暫時封鎖。",
"videoUnmuteBlockedTitle": "啟用網路攝影機與分享螢幕遭封鎖!",
"viewLobby": "檢視大廳",
"viewParticipants": "查看與會者",
"viewVisitors": "查看訪客",
"waitingParticipants": "{{waitingParticipants}} 人",
"waitingVisitors": "排隊中的訪客: {{waitingVisitors}} 人",
"waitingVisitorsTitle": "會議尚未開始!",
"whiteboardLimitDescription": "由於即將超出使用者限制,白板將關閉,請儲存您的進度。",
"whiteboardLimitTitle": "白板使用情況"
},
@@ -819,7 +886,10 @@
"audioModeration": "自我解除靜音",
"blockEveryoneMicCamera": "停用所有人的麥克風和網路攝影機",
"breakoutRooms": "分組討論室",
"goLive": "開始直播",
"invite": "邀請他人",
"lowerAllHands": "全部取消舉手",
"lowerHand": "取消舉手",
"moreModerationActions": "更多主持人選項",
"moreModerationControls": "更多主持人操作",
"moreParticipantOptions": "更多與會者選項",
@@ -836,10 +906,13 @@
"headings": {
"lobby": "大廳({{count}} 人)",
"participantsList": "會議與會者({{count}} 人)",
"visitorInQueue": "{{count}} 人等候中)",
"visitorRequests": "{{count}} 人申請",
"visitors": "訪客({{count}} 人)",
"waitingLobby": "於大廳等候({{count}} 人)"
},
"search": "搜尋與會者",
"searchDescription": "輸入關鍵字篩選與會者",
"title": "與會者"
},
"passwordDigitsOnly": "上限為 {{number}} 位數",
@@ -848,10 +921,13 @@
"pinnedParticipant": "與會者被釘選",
"polls": {
"answer": {
"edit": "編輯",
"send": "送出",
"skip": "跳過",
"submit": "送出"
},
"by": "由 {{ name }}",
"closeButton": "結束投票",
"create": {
"addOption": "新增選項",
"answerPlaceholder": "選項 {{index}}",
@@ -861,6 +937,7 @@
"pollQuestion": "投票問題",
"questionPlaceholder": "詢問問題",
"removeOption": "移除選項",
"save": "儲存",
"send": "傳送"
},
"errors": {
@@ -890,9 +967,11 @@
"configuringDevices": "設定裝置中…",
"connectedWithAudioQ": "您有連接音訊設備嗎?",
"connection": {
"good": "您的連線品質良好",
"failed": "連線測試失敗!",
"good": "您的連線品質良好!",
"nonOptimal": "您的連線品質不理想",
"poor": "您的連線品質不佳"
"poor": "您的連線品質不佳",
"running": "正在測試連線中…"
},
"connectionDetails": {
"audioClipping": "您的音訊將會斷斷續續。",
@@ -901,6 +980,7 @@
"goodQuality": "太好了!您的媒體品質良好。",
"noMediaConnectivity": "我們無法為此測試建立媒體連線,通常是防火牆或 NAT 的問題。",
"noVideo": "您的視訊畫質將會很糟糕。",
"testFailed": "連線測試遇到異常,但不一定會影響會議。",
"undetectable": "如果您仍無法在瀏覽器中進行通話,我們建議您檢查喇叭、麥克風、及網路攝影機的設置,確認是否允許瀏覽器存取麥克風及網路攝影機,並將瀏覽器更新到最新版本。如果以上步驟無法解決問題,請聯絡網頁程式的開發者。",
"veryPoorConnection": "您的通話品質將會非常糟糕。",
"videoFreezing": "您的視訊將會突然黑頻、卡住、或像素化。",
@@ -933,6 +1013,7 @@
"or": "或",
"premeeting": "會議前",
"proceedAnyway": "仍然繼續",
"recordingWarning": "其他與會者可能正在錄製此通話",
"screenSharingError": "螢幕分享錯誤:",
"startWithPhone": "使用手機音訊開始",
"unsafeRoomConsent": "我了解風險,我想要加入會議",
@@ -998,7 +1079,6 @@
"limitNotificationDescriptionNative": "由於目前流量過大,您的錄製時間被限制在 {{limit}} 分鐘。若要無限制的錄製,請試試 <3>{{app}}</3>。",
"limitNotificationDescriptionWeb": "由於目前流量過大,您的錄製時間被限制在 {{limit}} 分鐘。若要無限制的錄製,請試試 <a href={{url}}rel='noopener noreferrer' target='_blank'>{{app}}</a>。",
"linkGenerated": "我們建立了您的錄製檔案的連結。",
"live": "直播",
"localRecordingNoNotificationWarning": "系統不會主動知會與會者錄製已開啟,主持人需另行通知。",
"localRecordingNoVideo": "沒有錄製的視訊",
"localRecordingStartWarning": "請確保在退出會議之前停用錄製以便保存。",
@@ -1015,13 +1095,16 @@
"onBy": "{{name}} 開始了錄製",
"onlyRecordSelf": "僅錄製我的音訊和影片串流",
"pending": "正在準備錄製會議…",
"rec": "錄製中",
"policyError": "您啟動錄製的速度過快,請稍後再試!",
"recordAudioAndVideo": "錄製音訊和視訊",
"recordTranscription": "錄製字幕內容",
"saveLocalRecording": "將錄製檔案保存在本機(測試版)",
"serviceDescription": "您的錄製會由錄製服務儲存",
"serviceDescriptionCloud": "雲端錄製",
"serviceDescriptionCloudInfo": "已錄製的會議將在 24 小時後自動清除。",
"serviceName": "錄製服務",
"sessionAlreadyActive": "已在錄製或直播此工作階段。",
"showAdvancedOptions": "進階選項",
"signIn": "登入",
"signOut": "登出",
"surfaceError": "請選擇目前分頁",
@@ -1056,11 +1139,13 @@
"signedIn": "目前正在存取 {{email}} 的行事曆事件,點按下方中斷連接可以停用存取行事曆事件。",
"title": "行事曆"
},
"chatWithPermissions": "聊天功能需取得權限",
"desktopShareFramerate": "桌面螢幕分享影格率",
"desktopShareHighFpsWarning": "較高的桌面螢幕分享影格率可能會影響您的頻寬,您必須重新啟動桌面螢幕分享以套用新的設定。",
"desktopShareWarning": "您必須重新啟動桌面螢幕分享以套用新的設定。",
"devices": "裝置",
"followMe": "全部人跟隨我",
"followMeRecorder": "錄影將跟隨我的視角",
"framesPerSecond": "fps",
"incomingMessage": "新訊息",
"language": "語言",
@@ -1084,6 +1169,7 @@
"selectMic": "麥克風",
"selfView": "自身螢幕",
"shortcuts": "快捷鍵",
"showSubtitlesOnStage": "在主畫面顯示字幕",
"speakers": "喇叭",
"startAudioMuted": "所有人啟動時處於靜音",
"startReactionsMuted": "關閉所有人反應音效",
@@ -1137,11 +1223,13 @@
"fearful": "可怕",
"happy": "笑臉",
"hours": "{{count}} 小時",
"labelTooltip": "與會者人數:{{count}}",
"minutes": "{{count}} 分",
"name": "名字",
"neutral": "中立",
"sad": "悲傷",
"search": "搜尋",
"searchDescription": "輸入關鍵字篩選與會者",
"searchHint": "搜尋與會者",
"seconds": "{{count}} 秒",
"speakerStats": "發言統計",
@@ -1169,7 +1257,7 @@
"audioOnly": "切換僅音訊",
"audioRoute": "選擇音訊裝置",
"boo": "倒喝彩",
"breakoutRoom": "進入/離開分組討論室",
"breakoutRooms": "分組討論室",
"callQuality": "管理視訊品質",
"carmode": "行車模式",
"cc": "切換字幕",
@@ -1178,6 +1266,7 @@
"closeChat": "關閉聊天",
"closeMoreActions": "關閉更多操作選單",
"closeParticipantsPane": "關閉與會者窗格",
"closedCaptions": "停用即時字幕",
"collapse": "收回",
"document": "切換檔案分享",
"documentClose": "關閉檔案分享",
@@ -1207,6 +1296,7 @@
"lobbyButton": "啟用/停用大廳模式",
"localRecording": "切換本機錄製控制",
"lockRoom": "切換會議密碼",
"love": "愛心",
"lowerHand": "放下手",
"moreActions": "更多動作",
"moreActionsMenu": "更多動作選單",
@@ -1217,13 +1307,14 @@
"muteEveryoneElsesVideoStream": "停止其他人的視訊",
"muteEveryonesVideoStream": "停止所有人的視訊",
"muteGUMPending": "正在連接您的麥克風",
"noiseSuppression": "雜訊抑制",
"noiseSuppression": "雜訊抑制BETA",
"openChat": "打開聊天",
"participants": "打開與會者窗格",
"pip": "切換子母螢幕模式",
"privateMessage": "傳送私人訊息",
"profile": "編輯您的個人檔案",
"raiseHand": "舉手",
"react": "訊息反應",
"reactions": "反應",
"reactionsMenu": "反應選單",
"recording": "切換錄製",
@@ -1266,14 +1357,15 @@
"closeChat": "關閉聊天",
"closeParticipantsPane": "關閉與會者窗格",
"closeReactionsMenu": "關閉反應選單",
"disableNoiseSuppression": "停用雜訊抑制",
"closedCaptions": "停用即時字幕",
"disableNoiseSuppression": "停用雜訊抑制BETA",
"disableReactionSounds": "您可以停用此會議的反應音效",
"documentClose": "關閉分享檔案欄",
"documentOpen": "開啟分享檔案欄",
"download": "下載我們的應用程式",
"e2ee": "端對端加密",
"embedMeeting": "嵌入會議",
"enableNoiseSuppression": "開啟雜訊抑制",
"enableNoiseSuppression": "開啟雜訊抑制BETA",
"endConference": "結束會議(所有人)",
"enterFullScreen": "放大全螢幕",
"enterTileView": "進入畫廊檢視",
@@ -1295,6 +1387,7 @@
"lobbyButtonEnable": "啟用大廳模式",
"login": "登入",
"logout": "登出",
"love": "愛心",
"lowerYourHand": "放下您的手",
"moreActions": "更多動作",
"moreOptions": "更多選項",
@@ -1307,7 +1400,7 @@
"noAudioSignalDialInDesc": "您亦可使用下述方式撥入:",
"noAudioSignalDialInLinkDesc": "撥入號碼",
"noAudioSignalTitle": "您的麥克風沒有訊號!",
"noiseSuppression": "雜訊抑制",
"noiseSuppression": "雜訊抑制BETA",
"noisyAudioInputDesc": "噪音聽起來是從您的麥克風傳來的,請考慮靜音或更換裝置。",
"noisyAudioInputTitle": "您的麥克風疑似有雜音!",
"openChat": "開啟聊天",
@@ -1320,6 +1413,7 @@
"raiseYourHand": "舉手",
"reactionBoo": "傳送倒喝彩反應",
"reactionClap": "傳送鼓掌反應",
"reactionHeart": "傳送愛心反應",
"reactionLaugh": "傳送大笑反應",
"reactionLike": "傳送比讚反應",
"reactionSilence": "傳送沉默反應",
@@ -1352,21 +1446,22 @@
},
"transcribing": {
"ccButtonTooltip": "啟動/停用字幕",
"error": "轉錄失敗,請再試一次。",
"expandedLabel": "轉錄已開啟",
"failedToStart": "轉錄啟動失敗",
"labelToolTip": "此會議正在轉錄",
"off": "轉錄已停用",
"pending": "準備轉錄會議…",
"failed": "轉錄失敗",
"labelTooltip": "此會議正在轉錄",
"labelTooltipExtra": "稍後將提供完整轉錄紀錄",
"openClosedCaptions": "開啟字幕",
"original": "原文",
"sourceLanguageDesc": "會議語言目前設定為 <b>{{sourceLanguage}}</b><br/> 您可以在這裡",
"sourceLanguageHere": "修改",
"start": "開始顯示字幕",
"stop": "停用顯示字幕",
"subtitles": "字幕",
"subtitlesOff": "關",
"tr": "轉錄"
"tr": "轉錄",
"translateTo": "翻譯成"
},
"unpinParticipant": "",
"unpinParticipant": "{{participantName}} - 取消釘選",
"userMedia": {
"grantPermissions": "請允許使用您的網路攝影機和麥克風的權限."
},
@@ -1404,6 +1499,7 @@
},
"videothumbnail": {
"connectionInfo": "連線資訊",
"demote": "轉為訪客",
"domute": "靜音",
"domuteOthers": "靜音其他人",
"domuteVideo": "停用網路攝影機",
@@ -1456,11 +1552,22 @@
},
"visitors": {
"chatIndicator": "(訪客)",
"joinMeeting": {
"description": "您目前以訪客身分參與本會議",
"raiseHand": "舉手",
"title": "正在加入會議",
"wishToSpeak": "若您想發言,請先舉手並等候主持人同意"
},
"labelTooltip": "訪客數量:{{count}}",
"notification": {
"description": "若要參與請舉手",
"demoteDescription": "由 {{actor}} 調整為訪客,若要參與請舉手",
"noMainParticipantsDescription": "須有主要與會者啟動會議,請稍後再試",
"noMainParticipantsTitle": "會議尚未開始",
"noVisitorLobby": "此會議啟用大廳,暫時無法加入",
"notAllowedPromotion": "需由與會者同意您的申請",
"title": "您是會議中的訪客"
}
},
"waitingMessage": "會議開始後您將自動加入!"
},
"volumeSlider": "音量滑桿",
"welcomepage": {
@@ -1518,6 +1625,7 @@
"whiteboard": {
"accessibilityLabel": {
"heading": "白板"
}
},
"screenTitle": "白板"
}
}

View File

@@ -109,6 +109,7 @@
}
},
"chat": {
"disabled": "Sending chat messages is disabled.",
"enter": "Enter room",
"error": "Error: your message was not sent. Reason: {{error}}",
"fieldPlaceHolder": "Aa",
@@ -149,7 +150,7 @@
"buttonText": "Install Chrome Extension",
"buttonTextEdge": "Install Edge Extension",
"close": "Close",
"dontShowAgain": "Dont show me this again",
"dontShowAgain": "Don't show me this again",
"installExtensionText": "Install the extension for Google Calendar and Office 365 integration"
},
"closedCaptionsTab": {
@@ -232,8 +233,8 @@
"joinInBrowser": "Join in browser",
"launchMeetingLabel": "How do you want to join this meeting?",
"launchWebButton": "Launch in web",
"noDesktopApp": "You dont have the app?",
"noMobileApp": "You dont have the app?",
"noDesktopApp": "You don't have the app?",
"noMobileApp": "You don't have the app?",
"or": "OR",
"termsAndConditions": "By continuing you agree to our <a href='{{termsAndConditionsLink}}' rel='noopener noreferrer' target='_blank'>terms & conditions.</a>",
"title": "Launching your meeting in {{app}}…",
@@ -325,7 +326,7 @@
"demoteParticipantDialog": "Are you sure you want to move this participant to viewer?",
"demoteParticipantTitle": "Move to viewer",
"dismiss": "Dismiss",
"displayNameRequired": "Hi! Whats your name?",
"displayNameRequired": "Hi! What's your name?",
"done": "Done",
"e2eeDescription": "End-to-End Encryption is currently EXPERIMENTAL. Please keep in mind that turning on end-to-end encryption will effectively disable server-side provided services such as: phone participation. Also keep in mind that the meeting will only work for people joining from browsers with support for insertable streams.",
"e2eeDisabledDueToMaxModeDescription": "Cannot enable End-to-End Encryption due to large number of participants in the conference.",
@@ -431,7 +432,7 @@
"reservationErrorMsg": "Error code: {{code}}, message: {{msg}}",
"retry": "Retry",
"screenSharingAudio": "Share audio",
"screenSharingFailed": "Oops! Something went wrong, we werent able to start screen sharing!",
"screenSharingFailed": "Oops! Something went wrong, we weren't able to start screen sharing!",
"screenSharingFailedTitle": "Screen sharing failed!",
"screenSharingPermissionDeniedError": "Oops! Something went wrong with your screen sharing permissions. Please reload and try again.",
"searchInSalesforce": "Search in Salesforce",
@@ -460,7 +461,7 @@
"shareScreenWarningD2": "you need to stop audio sharing, start screen sharing and check the \"share audio\" option.",
"shareScreenWarningH1": "If you want to share just your screen:",
"shareScreenWarningTitle": "You need to stop audio sharing before sharing your screen",
"shareVideoConfirmPlay": "Youre about to open an external website. Do you want to continue?",
"shareVideoConfirmPlay": "You're about to open an external website. Do you want to continue?",
"shareVideoConfirmPlayTitle": "{{name}} has shared a video with you.",
"shareVideoLinkError": "Oops, this video cannot be played.",
"shareVideoLinkStopped": "The video from {{name}} was stopped.",
@@ -546,8 +547,10 @@
"downloadFailedDescription": "Please try again.",
"downloadFailedTitle": "Download failed",
"downloadFile": "Download",
"dragAndDrop": "Drag and drop files here",
"fileAlreadyUploaded": "File has already been uploaded to this meeting",
"dragAndDrop": "Drag and drop files here or anywhere on screen",
"fileAlreadyUploaded": "File has already been uploaded to this meeting.",
"fileTooLargeDescription": "Please make sure the file does not exceed {{ maxFileSize }}.",
"fileTooLargeTitle": "The selected file is too large",
"removeFile": "Remove",
"uploadFailedDescription": "Please try again.",
"uploadFailedTitle": "Upload failed",
@@ -662,7 +665,7 @@
"expandedOn": "The meeting is currently being live streamed",
"expandedPending": "The live streaming is being started…",
"failedToStart": "Live Streaming failed to start",
"getStreamKeyManually": "We werent able to fetch any live streams. Try getting your live stream key from YouTube.",
"getStreamKeyManually": "We weren't able to fetch any live streams. Try getting your live stream key from YouTube.",
"googlePrivacyPolicy": "Google Privacy Policy",
"inProgress": "Recording or live streaming in progress",
"invalidStreamKey": "Live stream key may be incorrect.",
@@ -903,9 +906,11 @@
"headings": {
"lobby": "Lobby ({{count}})",
"participantsList": "Meeting participants ({{count}})",
"viewerRequests": "Viewers requests {{count}}",
"visitorInQueue": " (waiting {{count}})",
"visitorRequests": " (requests {{count}})",
"visitors": "Viewers {{count}}",
"visitorsList": "Viewers ({{count}})",
"waitingLobby": "Waiting in lobby ({{count}})"
},
"search": "Search participants",
@@ -946,7 +951,7 @@
},
"results": {
"changeVote": "Change vote",
"empty": "There are no polls in the meeting yet. Start a poll here!",
"empty": "There are no polls in the meeting yet.",
"hideDetailedResults": "Hide details",
"showDetailedResults": "Show details",
"vote": "Vote"
@@ -962,7 +967,7 @@
"callMeAtNumber": "Call me at this number:",
"calling": "Calling",
"configuringDevices": "Configuring devices…",
"connectedWithAudioQ": "Youre connected with audio?",
"connectedWithAudioQ": "You're connected with audio?",
"connection": {
"failed": "Connection test failed!",
"good": "Your internet connection looks good!",
@@ -1559,7 +1564,7 @@
"notification": {
"demoteDescription": "Sent here by {{actor}}, raise your hand to participate",
"noMainParticipantsDescription": "A participant needs to start the meeting. Please try again in a bit.",
"noMainParticipantsTitle": "This meeting hasnt started yet.",
"noMainParticipantsTitle": "This meeting hasn't started yet.",
"noVisitorLobby": "You cannot join while there is a lobby enabled for the meeting.",
"notAllowedPromotion": "A participant needs to allow your request first.",
"title": "You are a viewer in the meeting"

View File

@@ -108,6 +108,7 @@ import {
} from '../../react/features/participants-pane/actions';
import { getParticipantsPaneOpen, isForceMuted } from '../../react/features/participants-pane/functions';
import { startLocalVideoRecording, stopLocalVideoRecording } from '../../react/features/recording/actions.any';
import { grantRecordingConsent, grantRecordingConsentAndUnmute } from '../../react/features/recording/actions.web';
import { RECORDING_METADATA_ID, RECORDING_TYPES } from '../../react/features/recording/constants';
import { getActiveSession, supportsLocalRecording } from '../../react/features/recording/functions';
import { startAudioScreenShareFlow, startScreenShareFlow } from '../../react/features/screen-share/actions';
@@ -209,6 +210,10 @@ function initCommands() {
}
APP.store.dispatch(grantModerator(participantId));
},
'grant-recording-consent': unmute => {
unmute ? APP.store.dispatch(grantRecordingConsentAndUnmute())
: APP.store.dispatch(grantRecordingConsent());
},
'display-name': displayName => {
sendAnalytics(createApiEvent('display.name.changed'));
APP.store.dispatch(updateSettings({ displayName: getNormalizedDisplayName(displayName) }));
@@ -769,7 +774,7 @@ function initCommands() {
}
if (transcription) {
APP.store.dispatch(setRequestingSubtitles(true, false, null));
APP.store.dispatch(setRequestingSubtitles(true, false, null, true));
conference.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: true
});
@@ -1918,6 +1923,19 @@ class API {
});
}
/**
* Notify external application (if API is enabled) that the recording consent dialog open state has changed.
*
* @param {boolean} open - True if the dialog is open, false otherwise.
* @returns {void}
*/
notifyRecordingConsentDialogOpen(open) {
this._sendEvent({
name: 'recording-consent-dialog-open',
open
});
}
/**
* Notify external application of the current meeting requiring a password
* to join.

View File

@@ -38,6 +38,7 @@ const commands = {
endConference: 'end-conference',
email: 'email',
grantModerator: 'grant-moderator',
grantRecordingConsent: 'grant-recording-consent',
hangup: 'video-hangup',
hideNotification: 'hide-notification',
initiatePrivateChat: 'initiate-private-chat',
@@ -151,6 +152,7 @@ const events = {
'proxy-connection-event': 'proxyConnectionEvent',
'raise-hand-updated': 'raiseHandUpdated',
'ready': 'ready',
'recording-consent-dialog-open': 'recordingConsentDialogOpen',
'recording-link-available': 'recordingLinkAvailable',
'recording-status-changed': 'recordingStatusChanged',
'participant-menu-button-clicked': 'participantMenuButtonClick',

2831
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,12 +16,13 @@
"author": "",
"readmeFilename": "README.md",
"dependencies": {
"@amplitude/react-native": "2.17.3",
"@amplitude/analytics-browser": "2.17.12",
"@amplitude/analytics-react-native": "1.4.13",
"@braintree/sanitize-url": "7.0.0",
"@emotion/react": "11.10.6",
"@emotion/styled": "11.10.6",
"@giphy/js-fetch-api": "4.7.1",
"@giphy/react-components": "6.8.1",
"@giphy/js-fetch-api": "4.9.3",
"@giphy/react-components": "6.9.4",
"@giphy/react-native-sdk": "2.3.0",
"@jitsi/excalidraw": "https://github.com/jitsi/excalidraw/releases/download/v0.0.19/jitsi-excalidraw-0.0.19.tgz",
"@jitsi/js-utils": "2.2.1",
@@ -54,6 +55,7 @@
"bc-css-flags": "3.0.0",
"clipboard-copy": "4.0.1",
"clsx": "1.1.1",
"dayjs": "1.11.13",
"dropbox": "10.7.0",
"focus-visible": "5.1.0",
"grapheme-splitter": "1.0.4",
@@ -67,10 +69,8 @@
"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/v1988.0.0+83c2ac30/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2025.0.0+49eb29a8/lib-jitsi-meet.tgz",
"lodash-es": "4.17.21",
"moment": "2.29.4",
"moment-duration-format": "2.2.2",
"null-loader": "4.0.1",
"optional-require": "1.0.3",
"pixelmatch": "5.3.0",
@@ -111,6 +111,7 @@
"react-native-youtube-iframe": "2.3.0",
"react-redux": "7.2.9",
"react-textarea-autosize": "8.3.0",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "1.8.6",
"react-youtube": "10.1.0",
"redux": "4.0.4",
@@ -131,7 +132,7 @@
"@babel/plugin-transform-private-methods": "7.25.9",
"@babel/preset-env": "7.25.9",
"@babel/preset-react": "7.25.9",
"@jitsi/eslint-config": "6.0.1",
"@jitsi/eslint-config": "6.0.4",
"@react-native/metro-config": "0.75.5",
"@types/amplitude-js": "8.16.5",
"@types/audioworklet": "0.0.29",
@@ -139,9 +140,7 @@
"@types/js-md5": "0.4.3",
"@types/jsonwebtoken": "9.0.7",
"@types/lodash-es": "4.17.12",
"@types/minimatch": "5.1.2",
"@types/mocha": "10.0.10",
"@types/moment-duration-format": "2.2.6",
"@types/offscreencanvas": "2019.7.2",
"@types/pixelmatch": "5.2.5",
"@types/pretty": "2.0.3",
@@ -158,12 +157,12 @@
"@types/w3c-image-capture": "1.0.6",
"@types/w3c-web-hid": "1.0.3",
"@types/zxcvbn": "4.4.1",
"@wdio/allure-reporter": "9.4.3",
"@wdio/cli": "9.4.3",
"@wdio/globals": "9.4.3",
"@wdio/junit-reporter": "9.4.3",
"@wdio/local-runner": "9.4.3",
"@wdio/mocha-framework": "9.4.3",
"@wdio/allure-reporter": "9.16.0",
"@wdio/cli": "9.16.0",
"@wdio/globals": "9.16.0",
"@wdio/junit-reporter": "9.16.0",
"@wdio/local-runner": "9.16.0",
"@wdio/mocha-framework": "9.16.0",
"babel-loader": "9.1.0",
"babel-plugin-optional-require": "0.3.1",
"circular-dependency-plugin": "5.2.0",
@@ -188,7 +187,7 @@
"ts-loader": "9.4.2",
"typescript": "5.7.2",
"unorm": "1.6.0",
"webdriverio": "9.4.3",
"webdriverio": "9.16.0",
"webpack": "5.95.0",
"webpack-bundle-analyzer": "4.4.2",
"webpack-cli": "5.1.4",

View File

@@ -22,7 +22,9 @@
"@react-navigation/stack": "0.0.0",
"@stomp/stompjs": "0.0.0",
"@xmldom/xmldom": "0.0.0",
"abab": "0.0.0",
"base64-js": "0.0.0",
"dayjs": "0.0.0",
"grapheme-splitter": "0.0.0",
"i18n-iso-countries": "0.0.0",
"i18next": "0.0.0",
@@ -32,8 +34,6 @@
"jwt-decode": "0.0.0",
"lib-jitsi-meet": "0.0.0",
"lodash-es": "0.0.0",
"moment": "0.0.0",
"moment-duration-format": "0.0.0",
"optional-require": "0.0.0",
"promise.allsettled": "0.0.0",
"promise.withresolvers": "0.0.0",
@@ -57,7 +57,7 @@
"zxcvbn": "0.0.0"
},
"peerDependencies": {
"@amplitude/react-native": "0.0.0",
"@amplitude/analytics-react-native": "0.0.0",
"@giphy/react-native-sdk": "0.0.0",
"@react-native-async-storage/async-storage": "0.0.0",
"@react-native-clipboard/clipboard": "0.0.0",

View File

@@ -84,7 +84,6 @@ export async function createHandlers({ getState }: IStore) {
} = config;
const {
amplitudeAPPKey,
amplitudeIncludeUTM,
blackListedEvents,
scriptURLs,
matomoEndpoint,
@@ -94,7 +93,6 @@ export async function createHandlers({ getState }: IStore) {
const { group, user } = state['features/base/jwt'];
const handlerConstructorOptions = {
amplitudeAPPKey,
amplitudeIncludeUTM,
blackListedEvents,
envType: deploymentInfo?.envType || 'dev',
matomoEndpoint,
@@ -187,7 +185,6 @@ export function initAnalytics(store: IStore, handlers: Array<Object>): boolean {
overwritesDefaultLogoUrl?: boolean;
overwritesDeploymentUrls?: boolean;
overwritesLiveStreamingUrls?: boolean;
overwritesSalesforceUrl?: boolean;
overwritesSupportUrl?: boolean;
server?: string;
tenant?: string;
@@ -229,7 +226,6 @@ export function initAnalytics(store: IStore, handlers: Array<Object>): boolean {
// TODO: Temporary metric. To be removed once we don't need it.
permanentProperties.overwritesSupportUrl = 'interfaceConfig.SUPPORT_URL' in params;
permanentProperties.overwritesSalesforceUrl = 'config.salesforceUrl' in params;
permanentProperties.overwritesDefaultLogoUrl = 'config.defaultLogoUrl' in params;
const deploymentUrlsConfig = params['config.deploymentUrls'] ?? {};

View File

@@ -11,7 +11,6 @@ export interface IEvent {
interface IOptions {
amplitudeAPPKey?: string;
amplitudeIncludeUTM?: boolean;
blackListedEvents?: string[];
envType?: string;
group?: string;

View File

@@ -1,22 +1,21 @@
import { Identify } from '@amplitude/analytics-core';
import logger from '../logger';
import AbstractHandler, { IEvent } from './AbstractHandler';
import { fixDeviceID } from './amplitude/fixDeviceID';
import amplitude from './amplitude/lib';
import amplitude, { initAmplitude } from './amplitude/lib';
/**
* Analytics handler for Amplitude.
*/
export default class AmplitudeHandler extends AbstractHandler {
_deviceId: string;
_userId: Object;
/**
* Creates new instance of the Amplitude analytics handler.
*
* @param {Object} options - The amplitude options.
* @param {string} options.amplitudeAPPKey - The Amplitude app key required by the Amplitude API.
* @param {boolean} options.amplitudeIncludeUTM - Whether to include UTM parameters
* @param {string} options.amplitudeAPPKey - The Amplitude app key required by the Amplitude API
* in the Amplitude events.
*/
constructor(options: any) {
@@ -24,62 +23,38 @@ export default class AmplitudeHandler extends AbstractHandler {
const {
amplitudeAPPKey,
amplitudeIncludeUTM: includeUtm = true,
user
} = options;
this._enabled = true;
const onError = (e: Error) => {
logger.error('Error initializing Amplitude', e);
this._enabled = false;
};
// Forces sending all events on exit (flushing) via sendBeacon
const onExitPage = () => {
// @ts-ignore
amplitude.getInstance().sendEvents();
};
if (navigator.product === 'ReactNative') {
amplitude.getInstance().init(amplitudeAPPKey);
fixDeviceID(amplitude.getInstance()).then(() => {
amplitude.getInstance().getDeviceId()
// @ts-ignore
.then((deviceId: string) => {
this._deviceId = deviceId;
});
initAmplitude(amplitudeAPPKey, user)
.then(() => {
logger.info('Amplitude initialized');
fixDeviceID(amplitude);
})
.catch(e => {
logger.error('Error initializing Amplitude', e);
this._enabled = false;
});
} else {
const amplitudeOptions: any = {
includeReferrer: true,
includeUtm,
saveParamsReferrerOncePerSession: false,
onError,
onExitPage
};
// @ts-ignore
amplitude.getInstance().init(amplitudeAPPKey, undefined, amplitudeOptions);
fixDeviceID(amplitude.getInstance());
}
if (user) {
this._userId = user;
amplitude.getInstance().setUserId(user);
}
}
/**
* Sets the Amplitude user properties.
*
* @param {Object} userProps - The user portperties.
* @param {Object} userProps - The user properties.
* @returns {void}
*/
setUserProperties(userProps: any) {
if (this._enabled) {
amplitude.getInstance().setUserProperties(userProps);
const identify = new Identify();
// Set all properties
Object.entries(userProps).forEach(([ key, value ]) => {
identify.set(key, value as any);
});
amplitude.identify(identify);
}
}
@@ -96,8 +71,9 @@ export default class AmplitudeHandler extends AbstractHandler {
return;
}
// @ts-ignore
amplitude.getInstance().logEvent(this._extractName(event) ?? '', event);
const eventName = this._extractName(event) ?? '';
amplitude.track(eventName, event);
}
/**
@@ -106,21 +82,10 @@ export default class AmplitudeHandler extends AbstractHandler {
* @returns {Object}
*/
getIdentityProps() {
if (navigator.product === 'ReactNative') {
return {
deviceId: this._deviceId,
userId: this._userId
};
}
return {
sessionId: amplitude.getInstance().getSessionId(),
// @ts-ignore
deviceId: amplitude.getInstance().options.deviceId,
// @ts-ignore
userId: amplitude.getInstance().options.userId
sessionId: amplitude.getSessionId(),
deviceId: amplitude.getDeviceId(),
userId: amplitude.getUserId()
};
}
}

View File

@@ -1,4 +1,4 @@
import { Amplitude } from '@amplitude/react-native';
import { Types } from '@amplitude/analytics-react-native';
import DefaultPreference from 'react-native-default-preference';
import { getUniqueId } from 'react-native-device-info';
@@ -8,16 +8,16 @@ import logger from '../../logger';
/**
* Custom logic for setting the correct device id.
*
* @param {AmplitudeClient} amplitude - The amplitude instance.
* @param {Types.ReactNativeClient} amplitude - The amplitude instance.
* @returns {void}
*/
export async function fixDeviceID(amplitude: Amplitude) {
export async function fixDeviceID(amplitude: Types.ReactNativeClient) {
await DefaultPreference.setName('jitsi-preferences');
const current = await DefaultPreference.get('amplitudeDeviceId');
if (current) {
await amplitude.setDeviceId(current);
amplitude.setDeviceId(current);
} else {
const uid = await getUniqueId();
@@ -27,7 +27,7 @@ export async function fixDeviceID(amplitude: Amplitude) {
return;
}
await amplitude.setDeviceId(uid as string);
amplitude.setDeviceId(uid as string);
await DefaultPreference.set('amplitudeDeviceId', uid as string);
}
}

View File

@@ -1,11 +1,37 @@
import { AmplitudeClient } from 'amplitude-js';
import { Types } from '@amplitude/analytics-browser';
// @ts-ignore
import { jitsiLocalStorage } from '@jitsi/js-utils';
import logger from '../../logger';
/**
* Key used to store the device id in local storage.
*/
const DEVICE_ID_KEY = '__AMDID';
/**
* Custom logic for setting the correct device id.
*
* @param {AmplitudeClient} _amplitude - The amplitude instance.
* @param {Types.BrowserClient} amplitude - The amplitude instance.
* @returns {void}
*/
export function fixDeviceID(_amplitude: AmplitudeClient): Promise<any> {
return new Promise(resolve => resolve(true));
export function fixDeviceID(amplitude: Types.BrowserClient) {
const deviceId = jitsiLocalStorage.getItem(DEVICE_ID_KEY);
if (deviceId) {
// Set the device id in Amplitude.
try {
amplitude.setDeviceId(JSON.parse(deviceId));
} catch (error) {
logger.error('Failed to set device ID in Amplitude', error);
return Promise.resolve(false);
}
} else {
const newDeviceId = amplitude.getDeviceId();
if (newDeviceId) {
jitsiLocalStorage.setItem(DEVICE_ID_KEY, JSON.stringify(newDeviceId));
}
}
}

View File

@@ -1,3 +1,15 @@
import { Amplitude } from '@amplitude/react-native';
import amplitude from '@amplitude/analytics-react-native';
export default Amplitude;
export default amplitude;
/**
* Initializes the Amplitude instance.
*
* @param {string} amplitudeAPPKey - The Amplitude app key.
* @param {string | undefined} user - The user ID.
* @returns {Promise} The initialized Amplitude instance.
*/
export function initAmplitude(
amplitudeAPPKey: string, user: string | undefined): Promise<unknown> {
return amplitude.init(amplitudeAPPKey, user, {}).promise;
}

View File

@@ -1,3 +1,38 @@
import amplitude from 'amplitude-js';
import { createInstance } from '@amplitude/analytics-browser';
const amplitude = createInstance();
export default amplitude;
/**
* Initializes the Amplitude instance.
*
* @param {string} amplitudeAPPKey - The Amplitude app key.
* @param {string | undefined} user - The user ID.
* @returns {Promise} The initialized Amplitude instance.
*/
export function initAmplitude(
amplitudeAPPKey: string, user: string | undefined): Promise<unknown> {
// Forces sending all events on exit (flushing) via sendBeacon.
window.addEventListener('pagehide', () => {
// Set https transport to use sendBeacon API.
amplitude.setTransport('beacon');
// Send all pending events to server.
amplitude.flush();
});
const options = {
autocapture: {
attribution: true,
pageViews: true,
sessions: false,
fileDownloads: false,
formInteractions: false,
elementInteractions: false
},
defaultTracking: false
};
return amplitude.init(amplitudeAPPKey, user, options).promise;
}

View File

@@ -1,5 +1,4 @@
import { MEDIA_TYPE } from '../base/media/constants';
import type { MediaType } from '../base/media/constants';
import { MEDIA_TYPE, type MediaType } from '../base/media/constants';
import {
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED

View File

@@ -24,20 +24,17 @@ MiddlewareRegistry.register(() => (next: Function) => (action: AnyAction) => {
case APP_WILL_MOUNT: {
// Disable it inside an iframe until Google fixes the origin trial for 3rd party sources:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1504167
if (!isEmbedded() && 'PressureObserver' in globalThis) {
if (!isEmbedded() && 'PressureObserver' in window) {
pressureObserver = new window.PressureObserver(
(records: typeof window.PressureRecord) => {
logger.info('Compute pressure state changed:', JSON.stringify(records));
if (typeof APP !== 'undefined') {
APP.API.notifyComputePressureChanged(records);
}
},
{ sampleRate: 1 }
APP.API.notifyComputePressureChanged(records);
}
);
try {
pressureObserver
.observe('cpu')
.observe('cpu', { sampleInterval: 30_000 })
.catch((e: any) => logger.error('CPU pressure observer failed to start', e));
} catch (e: any) {
logger.error('CPU pressure observer failed to start', e);

View File

@@ -64,9 +64,9 @@ function getFirstGraphemeUpper(word: string) {
export function getInitials(s?: string) {
// We don't want to use the domain part of an email address, if it is one
const initialsBasis = split(s, '@')[0];
const [ firstWord, secondWord ] = initialsBasis.split(wordSplitRegex).filter(Boolean);
const [ firstWord, ...remainingWords ] = initialsBasis.split(wordSplitRegex).filter(Boolean);
return getFirstGraphemeUpper(firstWord) + getFirstGraphemeUpper(secondWord);
return getFirstGraphemeUpper(firstWord) + getFirstGraphemeUpper(remainingWords.pop() || '');
}
/**

View File

@@ -394,19 +394,6 @@ export function isP2pActive(stateful: IStateful): boolean | null {
return conference.isP2PActive();
}
/**
* Returns whether the current conference has audio recording property which is on.
*
* @param {IStateful} stateful - The redux store, state, or {@code getState} function.
* @returns {boolean|null}
*/
export function isConferenceAudioRecordingOn(stateful: IStateful): boolean | null {
const state = getConferenceState(toState(stateful));
// @ts-ignore
return state.properties?.['audio-recording-enabled'] === 'true';
}
/**
* Returns the stored room name.
*

View File

@@ -16,18 +16,20 @@ import { IStore } from '../../app/types';
import { removeLobbyChatParticipant } from '../../chat/actions.any';
import { openDisplayNamePrompt } from '../../display-name/actions';
import { isVpaasMeeting } from '../../jaas/functions';
import { showErrorNotification, showNotification } from '../../notifications/actions';
import { clearNotifications, showErrorNotification, showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { INotificationProps } from '../../notifications/types';
import { hasDisplayName } from '../../prejoin/utils';
import { stopLocalVideoRecording } from '../../recording/actions.any';
import LocalRecordingManager from '../../recording/components/Recording/LocalRecordingManager';
import { AudioMixerEffect } from '../../stream-effects/audio-mixer/AudioMixerEffect';
import { iAmVisitor } from '../../visitors/functions';
import { overwriteConfig } from '../config/actions';
import { CONNECTION_ESTABLISHED, CONNECTION_FAILED } from '../connection/actionTypes';
import { CONNECTION_ESTABLISHED, CONNECTION_FAILED, CONNECTION_WILL_CONNECT } from '../connection/actionTypes';
import { connectionDisconnected, disconnect } from '../connection/actions';
import { validateJwt } from '../jwt/functions';
import { JitsiConferenceErrors, JitsiConferenceEvents, JitsiConnectionErrors } from '../lib-jitsi-meet';
import { MEDIA_TYPE } from '../media/constants';
import { PARTICIPANT_UPDATED, PIN_PARTICIPANT } from '../participants/actionTypes';
import { PARTICIPANT_ROLE } from '../participants/constants';
import {
@@ -97,6 +99,11 @@ MiddlewareRegistry.register(store => next => action => {
case CONNECTION_FAILED:
return _connectionFailed(store, next, action);
case CONNECTION_WILL_CONNECT:
// we are starting a new join process, let's clear the error notifications if any from any previous attempt
store.dispatch(clearNotifications());
break;
case CONFERENCE_SUBJECT_CHANGED:
return _conferenceSubjectChanged(store, next, action);
@@ -262,7 +269,9 @@ function _conferenceFailed({ dispatch, getState }: IStore, next: Function, actio
_removeUnloadHandler(getState);
}
if (enableForcedReload && error?.name === JitsiConferenceErrors.CONFERENCE_RESTARTED) {
if (enableForcedReload
&& (error?.name === JitsiConferenceErrors.CONFERENCE_RESTARTED
|| error?.name === JitsiConnectionErrors.SHARD_CHANGED_ERROR)) {
dispatch(conferenceWillLeave(conference));
dispatch(reloadNow());
}
@@ -654,7 +663,7 @@ function _setRoom({ dispatch, getState }: IStore, next: Function, action: AnyAct
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _trackAddedOrRemoved(store: IStore, next: Function, action: AnyAction) {
async function _trackAddedOrRemoved(store: IStore, next: Function, action: AnyAction) {
const track = action.track;
// TODO All track swapping should happen here instead of conference.js.
@@ -662,7 +671,6 @@ function _trackAddedOrRemoved(store: IStore, next: Function, action: AnyAction)
const { getState } = store;
const state = getState();
const conference = getCurrentConference(state);
let promise;
if (conference) {
const jitsiTrack = action.track.jitsiTrack;
@@ -671,14 +679,22 @@ function _trackAddedOrRemoved(store: IStore, next: Function, action: AnyAction)
// If gUM is slow and tracks are created after the user has already joined the conference, avoid
// adding the tracks to the conference if the user is a visitor.
if (!iAmVisitor(state)) {
promise = _addLocalTracksToConference(conference, [ jitsiTrack ]);
const { desktopAudioTrack } = state['features/screen-share'];
// If the user is sharing their screen and has a desktop audio track, we need to replace that with
// the audio mixer effect so that the desktop audio is mixed in with the microphone audio.
if (typeof APP !== 'undefined' && desktopAudioTrack && track.mediaType === MEDIA_TYPE.AUDIO) {
await conference.replaceTrack(desktopAudioTrack, null);
const audioMixerEffect = new AudioMixerEffect(desktopAudioTrack);
await jitsiTrack.setEffect(audioMixerEffect);
await conference.replaceTrack(null, jitsiTrack);
} else {
await _addLocalTracksToConference(conference, [ jitsiTrack ]);
}
}
} else {
promise = _removeLocalTracksFromConference(conference, [ jitsiTrack ]);
}
if (promise) {
return promise.then(() => next(action));
await _removeLocalTracksFromConference(conference, [ jitsiTrack ]);
}
}
}

View File

@@ -8,7 +8,7 @@ import { isPrejoinPageVisible } from '../../prejoin/functions';
import { iAmVisitor } from '../../visitors/functions';
import { CONNECTION_DISCONNECTED, CONNECTION_ESTABLISHED } from '../connection/actionTypes';
import { hangup } from '../connection/actions.web';
import { JitsiConferenceErrors, browser } from '../lib-jitsi-meet';
import { JitsiConferenceErrors, JitsiConnectionErrors, browser } from '../lib-jitsi-meet';
import { gumPending, setInitialGUMPromise } from '../media/actions';
import { MEDIA_TYPE } from '../media/constants';
import { IGUMPendingState } from '../media/types';
@@ -117,7 +117,9 @@ MiddlewareRegistry.register(store => next => action => {
case CONFERENCE_FAILED: {
const errorName = action.error?.name;
if (enableForcedReload && errorName === JitsiConferenceErrors.CONFERENCE_RESTARTED) {
if (enableForcedReload
&& (errorName === JitsiConferenceErrors.CONFERENCE_RESTARTED
|| errorName === JitsiConnectionErrors.SHARD_CHANGED_ERROR)) {
dispatch(setSkipPrejoinOnReload(true));
}
@@ -188,7 +190,8 @@ MiddlewareRegistry.register(store => next => action => {
return APP.conference.startConference(jitsiTracks);
});
})
.catch(logger.error);
});
} else {
promise.then(({ tracks }) => {

View File

@@ -98,6 +98,7 @@ export interface IJitsiConference {
end: Function;
getBreakoutRooms: Function;
getConnection: Function;
getFileSharing: Function;
getLocalParticipantProperty: Function;
getLocalTracks: Function;
getMeetingUniqueId: Function;
@@ -107,6 +108,7 @@ export interface IJitsiConference {
getParticipantCount: Function;
getParticipants: Function;
getRole: Function;
getShortTermCredentials: Function;
getSpeakerStats: () => ISpeakerStats;
getSsrcByTrack: Function;
getTranscriptionStatus: Function;

View File

@@ -1,9 +1,9 @@
import { IStore } from '../../app/types';
import { showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import StateListenerRegistry from '../redux/StateListenerRegistry';
import { setAudioMuted, setVideoMuted } from '../media/actions';
import { VIDEO_MUTISM_AUTHORITY } from '../media/constants';
import StateListenerRegistry from '../redux/StateListenerRegistry';
let hasShownNotification = false;

View File

@@ -86,15 +86,6 @@ export type Sounds = 'ASKED_TO_UNMUTE_SOUND' |
'RECORDING_ON_SOUND' |
'TALK_WHILE_MUTED_SOUND';
export interface IMobileDynamicLink {
apn: string;
appCode: string;
customDomain?: string;
ibi: string;
isi: string;
}
export interface IDeeplinkingPlatformConfig {
appName: string;
appScheme: string;
@@ -103,7 +94,6 @@ export interface IDeeplinkingPlatformConfig {
export interface IDeeplinkingMobileConfig extends IDeeplinkingPlatformConfig {
appPackage?: string;
downloadLink: string;
dynamicLink?: IMobileDynamicLink;
fDroidUrl?: string;
}
@@ -182,7 +172,6 @@ export interface IConfig {
_screenshotHistoryRegionUrl?: number;
analytics?: {
amplitudeAPPKey?: string;
amplitudeIncludeUTM?: boolean;
blackListedEvents?: string[];
disabled?: boolean;
matomoEndpoint?: string;
@@ -391,12 +380,15 @@ export interface IConfig {
fileSharing?: {
apiUrl?: string;
enabled?: boolean;
maxFileSize?: number;
};
filmstrip?: {
alwaysShowResizeBar?: boolean;
disableResizable?: boolean;
disableStageFilmstrip?: boolean;
disableTopPanel?: boolean;
disabled?: boolean;
initialWidth?: number;
minParticipantCountForTopPanel?: number;
};
flags?: {
@@ -561,7 +553,7 @@ export interface IConfig {
disableDemote?: boolean;
disableGrantModerator?: boolean;
disableKick?: boolean;
disablePrivateChat?: boolean;
disablePrivateChat?: 'all' | 'allow-moderator-chat';
disabled?: boolean;
};
replaceParticipant?: string;
@@ -603,6 +595,7 @@ export interface IConfig {
failICE?: boolean;
noAutoPlayVideo?: boolean;
p2pTestMode?: boolean;
showSpotConsentDialog?: boolean;
skipInterimTranscriptions?: boolean;
testMode?: boolean;
};
@@ -612,8 +605,9 @@ export interface IConfig {
};
tokenAuthUrl?: string;
tokenAuthUrlAutoRedirect?: string;
tokenGetUserInfoOutOfContext?: boolean;
tokenLogoutUrl?: string;
tokenRespectTenant?: string;
tokenRespectTenant?: boolean;
toolbarButtons?: Array<ToolbarButton>;
toolbarConfig?: {
alwaysVisible?: boolean;
@@ -628,6 +622,7 @@ export interface IConfig {
autoTranscribeOnRecord?: boolean;
disableClosedCaptions?: boolean;
enabled?: boolean;
inviteJigasiOnBackendTranscribing?: boolean;
preferredLanguage?: string;
translationLanguages?: Array<string>;
translationLanguagesHead?: Array<string>;

View File

@@ -193,6 +193,13 @@ export function overrideConfigJSON(config: IConfig, interfaceConfig: any, json:
* that are whitelisted.
*/
export function getWhitelistedJSON(configName: 'interfaceConfig' | 'config', configJSON: any): Object {
// Disable whitelisting in dev mode.
if (typeof __DEV__ !== 'undefined' && __DEV__) {
logger.warn('Whitelisting is disabled in dev mode, accepting any overrides');
return configJSON;
}
if (configName === 'interfaceConfig') {
return pick(configJSON, INTERFACE_CONFIG_WHITELIST);
} else if (configName === 'config') {
@@ -364,12 +371,6 @@ export function setConfigFromURLParams(
logger.warn('Using liveStreaming config URL overwrite and/or LIVE_STREAMING_HELP_LINK interfaceConfig URL'
+ ' overwrite is deprecated. Please use liveStreaming from advanced branding!');
}
if ('config.customToolbarButtons' in params) {
logger.warn('Using customToolbarButtons config URL overwrite is deprecated.'
+ ' Please use liveStreaming from advanced branding!');
}
}
/* eslint-enable max-params */

View File

@@ -77,12 +77,6 @@ export function _setDeeplinkingDefaults(deeplinking: IDeeplinkingConfig) {
ios.appScheme = ios.appScheme || 'org.jitsi.meet';
ios.downloadLink = ios.downloadLink
|| 'https://itunes.apple.com/us/app/jitsi-meet/id1165103905';
if (ios.dynamicLink) {
ios.dynamicLink.apn = ios.dynamicLink.apn || 'org.jitsi.meet';
ios.dynamicLink.appCode = ios.dynamicLink.appCode || 'w2atb';
ios.dynamicLink.ibi = ios.dynamicLink.ibi || 'com.atlassian.JitsiMeet.ios';
ios.dynamicLink.isi = ios.dynamicLink.isi || '1165103905';
}
android.appName = android.appName || 'Jitsi Meet';
android.appScheme = android.appScheme || 'org.jitsi.meet';
@@ -90,10 +84,4 @@ export function _setDeeplinkingDefaults(deeplinking: IDeeplinkingConfig) {
|| 'https://play.google.com/store/apps/details?id=org.jitsi.meet';
android.appPackage = android.appPackage || 'org.jitsi.meet';
android.fDroidUrl = android.fDroidUrl || 'https://f-droid.org/packages/org.jitsi.meet/';
if (android.dynamicLink) {
android.dynamicLink.apn = android.dynamicLink.apn || 'org.jitsi.meet';
android.dynamicLink.appCode = android.dynamicLink.appCode || 'w2atb';
android.dynamicLink.ibi = android.dynamicLink.ibi || 'com.atlassian.JitsiMeet.ios';
android.dynamicLink.isi = android.dynamicLink.isi || '1165103905';
}
}

View File

@@ -6,6 +6,5 @@ export default [
'customToolbarButtons',
'defaultLogoUrl',
'deploymentUrls',
'liveStreaming',
'salesforceUrl'
'liveStreaming'
];

View File

@@ -2,6 +2,7 @@ import { AnyAction } from 'redux';
import { IStore } from '../../app/types';
import { SET_DYNAMIC_BRANDING_DATA } from '../../dynamic-branding/actionTypes';
import { setUserFilmstripWidth } from '../../filmstrip/actions.web';
import { getFeatureFlag } from '../flags/functions';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { updateSettings } from '../settings/actions';
@@ -79,12 +80,18 @@ function _setConfig({ dispatch, getState }: IStore, next: Function, action: AnyA
}));
}
if (action.config.filmstrip?.stageFilmstripParticipants !== undefined) {
const { initialWidth, stageFilmstripParticipants } = action.config.filmstrip || {};
if (stageFilmstripParticipants !== undefined) {
dispatch(updateSettings({
maxStageParticipants: action.config.filmstrip.stageFilmstripParticipants
maxStageParticipants: stageFilmstripParticipants
}));
}
if (initialWidth) {
dispatch(setUserFilmstripWidth(initialWidth));
}
dispatch(updateConfig(config));
// FIXME On Web we rely on the global 'config' variable which gets altered

View File

@@ -18,8 +18,7 @@ import {
IConfig,
IDeeplinkingConfig,
IDeeplinkingDesktopConfig,
IDeeplinkingMobileConfig,
IMobileDynamicLink
IDeeplinkingMobileConfig
} from './configType';
import { _cleanupConfig, _setDeeplinkingDefaults } from './functions';
@@ -186,6 +185,15 @@ function _setConfig(state: IConfig, { config }: { config: IConfig; }) {
});
}
const { alwaysShowResizeBar, disableResizable } = config.filmstrip || {};
if (alwaysShowResizeBar && disableResizable) {
config.filmstrip = {
...config.filmstrip,
alwaysShowResizeBar: false
};
}
const newState = merge(
{},
config,
@@ -321,15 +329,6 @@ function _translateInterfaceConfig(oldValue: IConfig) {
};
if (typeof interfaceConfig === 'object') {
const mobileDynamicLink = interfaceConfig.MOBILE_DYNAMIC_LINK;
const dynamicLink: IMobileDynamicLink | undefined = mobileDynamicLink ? {
apn: mobileDynamicLink.APN,
appCode: mobileDynamicLink.APP_CODE,
ibi: mobileDynamicLink.IBI,
isi: mobileDynamicLink.ISI,
customDomain: mobileDynamicLink.CUSTOM_DOMAIN
} : undefined;
if (deeplinking.desktop) {
deeplinking.desktop.appName = interfaceConfig.NATIVE_APP_NAME;
}
@@ -340,14 +339,12 @@ function _translateInterfaceConfig(oldValue: IConfig) {
appScheme: interfaceConfig.APP_SCHEME,
downloadLink: interfaceConfig.MOBILE_DOWNLOAD_LINK_ANDROID,
appPackage: interfaceConfig.ANDROID_APP_PACKAGE,
fDroidUrl: interfaceConfig.MOBILE_DOWNLOAD_LINK_F_DROID,
dynamicLink
fDroidUrl: interfaceConfig.MOBILE_DOWNLOAD_LINK_F_DROID
};
deeplinking.ios = {
appName: interfaceConfig.NATIVE_APP_NAME,
appScheme: interfaceConfig.APP_SCHEME,
downloadLink: interfaceConfig.MOBILE_DOWNLOAD_LINK_IOS,
dynamicLink
downloadLink: interfaceConfig.MOBILE_DOWNLOAD_LINK_IOS
};
}
newValue.deeplinking = deeplinking;

View File

@@ -1,7 +1,7 @@
import { appNavigate } from '../../app/actions.native';
import { IStore } from '../../app/types';
import { getCustomerDetails } from '../../jaas/actions.any';
import { isVpaasMeeting, getJaasJWT } from '../../jaas/functions';
import { getJaasJWT, isVpaasMeeting } from '../../jaas/functions';
import { navigateRoot } from '../../mobile/navigation/rootNavigationContainerRef';
import { screen } from '../../mobile/navigation/routes';
import { setJWT } from '../jwt/actions';

View File

@@ -1,65 +1,81 @@
import moment from 'moment';
import momentDurationFormatSetup from 'moment-duration-format';
import dayjs from 'dayjs';
import durationPlugin from 'dayjs/plugin/duration';
import localizedFormatPlugin from 'dayjs/plugin/localizedFormat';
import relativeTimePlugin from 'dayjs/plugin/relativeTime';
import i18next from './i18next';
// allows for moment durations to be formatted
momentDurationFormatSetup(moment);
dayjs.extend(durationPlugin);
dayjs.extend(relativeTimePlugin);
dayjs.extend(localizedFormatPlugin);
// MomentJS uses static language bundle loading, so in order to support dynamic
// Day.js uses static language bundle loading, so in order to support dynamic
// language selection in the app we need to load all bundles that we support in
// the app.
require('moment/locale/af');
require('moment/locale/ar');
require('moment/locale/be');
require('moment/locale/bg');
require('moment/locale/ca');
require('moment/locale/cs');
require('moment/locale/da');
require('moment/locale/de');
require('moment/locale/el');
require('moment/locale/en-gb');
require('moment/locale/eo');
require('moment/locale/es-us');
require('moment/locale/es');
require('moment/locale/et');
require('moment/locale/eu');
require('moment/locale/fa');
require('moment/locale/fi');
require('moment/locale/fr-ca');
require('moment/locale/fr');
require('moment/locale/gl');
require('moment/locale/he');
require('moment/locale/hr');
require('moment/locale/hu');
require('moment/locale/hy-am');
require('moment/locale/id');
require('moment/locale/is');
require('moment/locale/it');
require('moment/locale/ja');
require('moment/locale/ko');
require('moment/locale/lt');
require('moment/locale/lv');
require('moment/locale/ml');
require('moment/locale/mn');
require('moment/locale/mr');
require('moment/locale/nb');
require('moment/locale/nl');
require('moment/locale/oc-lnc');
require('moment/locale/pl');
require('moment/locale/pt');
require('moment/locale/pt-br');
require('moment/locale/ro');
require('moment/locale/ru');
require('moment/locale/sk');
require('moment/locale/sl');
require('moment/locale/sr');
require('moment/locale/sv');
require('moment/locale/tr');
require('moment/locale/uk');
require('moment/locale/vi');
require('moment/locale/zh-cn');
require('moment/locale/zh-tw');
import 'dayjs/locale/af';
import 'dayjs/locale/ar';
import 'dayjs/locale/be';
import 'dayjs/locale/bg';
import 'dayjs/locale/ca';
import 'dayjs/locale/cs';
import 'dayjs/locale/da';
import 'dayjs/locale/de';
import 'dayjs/locale/el';
import 'dayjs/locale/eo';
import 'dayjs/locale/es';
import 'dayjs/locale/es-us';
import 'dayjs/locale/et';
import 'dayjs/locale/eu';
import 'dayjs/locale/fa';
import 'dayjs/locale/fi';
import 'dayjs/locale/fr';
import 'dayjs/locale/fr-ca';
import 'dayjs/locale/gl';
import 'dayjs/locale/he';
import 'dayjs/locale/hi';
import 'dayjs/locale/hr';
import 'dayjs/locale/hu';
import 'dayjs/locale/hy-am';
import 'dayjs/locale/id';
import 'dayjs/locale/is';
import 'dayjs/locale/it';
import 'dayjs/locale/ja';
import 'dayjs/locale/ko';
import 'dayjs/locale/lt';
import 'dayjs/locale/lv';
import 'dayjs/locale/ml';
import 'dayjs/locale/mn';
import 'dayjs/locale/mr';
import 'dayjs/locale/nb';
import 'dayjs/locale/nl';
import 'dayjs/locale/oc-lnc';
import 'dayjs/locale/pl';
import 'dayjs/locale/pt';
import 'dayjs/locale/pt-br';
import 'dayjs/locale/ro';
import 'dayjs/locale/ru';
import 'dayjs/locale/sk';
import 'dayjs/locale/sl';
import 'dayjs/locale/sq';
import 'dayjs/locale/sr';
import 'dayjs/locale/sv';
import 'dayjs/locale/te';
import 'dayjs/locale/tr';
import 'dayjs/locale/uk';
import 'dayjs/locale/vi';
import 'dayjs/locale/zh-cn';
import 'dayjs/locale/zh-tw';
const LOCALE_MAPPING: Record<string, string> = {
// i18next -> dayjs
'hy': 'hy-am',
'oc': 'oc-lnc',
'zhCN': 'zh-cn',
'zhTW': 'zh-tw',
'ptBR': 'pt-br',
'esUS': 'es-us',
'frCA': 'fr-ca'
};
/**
* Returns a localized date formatter initialized with a specific {@code Date}
@@ -71,7 +87,7 @@ require('moment/locale/zh-tw');
* @returns {Object}
*/
export function getLocalizedDateFormatter(dateOrTimeStamp: Date | number) {
return moment(dateOrTimeStamp).locale(_getSupportedLocale());
return dayjs(dateOrTimeStamp).locale(_getSupportedLocale());
}
/**
@@ -84,23 +100,17 @@ export function getLocalizedDateFormatter(dateOrTimeStamp: Date | number) {
* @returns {Object}
*/
export function getLocalizedDurationFormatter(duration: number) {
// FIXME The flow-type definition of moment is v2.3 while our package.json
// states v2.19 so maybe locale on moment's duration was introduced in
// between?
//
// If the conference is under an hour long we want to display it without
// showing the hour and we want to include the hour if the conference is
// more than an hour long
// @ts-ignore
if (moment.duration(duration).format('h') !== '0') {
// @ts-ignore
return moment.duration(duration).format('h:mm:ss');
const d = dayjs.duration(duration);
if (d.hours() !== 0) {
return d.format('H:mm:ss');
}
// @ts-ignore
return moment.duration(duration).format('mm:ss', { trim: false });
return d.format('mm:ss');
}
/**
@@ -110,9 +120,18 @@ export function getLocalizedDurationFormatter(duration: number) {
* @returns {string}
*/
function _getSupportedLocale() {
const availableLocales = Object.keys(dayjs.Ls);
const i18nLocale = i18next.language;
let supportedLocale;
if (LOCALE_MAPPING[i18nLocale]) {
return LOCALE_MAPPING[i18nLocale];
}
if (availableLocales.includes(i18nLocale)) {
return i18nLocale;
}
if (i18nLocale) {
const localeRegexp = new RegExp('^([a-z]{2,2})(-)*([a-z]{2,2})*$');
const localeResult = localeRegexp.exec(i18nLocale.toLowerCase());
@@ -123,11 +142,7 @@ function _getSupportedLocale() {
`^${localeResult[1]}(-)*${`(${localeResult[3]})*` || ''}`);
supportedLocale
// FIXME The flow-type definition of moment is v2.3 while our
// package.json states v2.19 so maybe locales on moment was
// introduced in between?
= moment.locales().find(lang => currentLocaleRegexp.exec(lang));
= availableLocales.find(lang => currentLocaleRegexp.exec(lang));
}
}

View File

@@ -1,3 +1,13 @@
/**
* The type of redux action which set an avatar URL for delayed loading.
*
* {
* type: SET_DELAYED_LOAD_OF_AVATAR_URL
* avatarUrl: string
* }
*/
export const SET_DELAYED_LOAD_OF_AVATAR_URL = 'SET_DELAYED_LOAD_OF_AVATAR_URL';
/**
* The type of redux action which stores a specific JSON Web Token (JWT) into
* the redux store.
@@ -8,3 +18,13 @@
* }
*/
export const SET_JWT = 'SET_JWT';
/**
* The type of redux action which sets a known avatar URL.
*
* {
* type: SET_KNOWN_AVATAR_URL,
* avatarUrl: string
* }
*/
export const SET_KNOWN_AVATAR_URL = 'SET_KNOWN_AVATAR_URL';

View File

@@ -1,11 +1,27 @@
import { SET_JWT } from './actionTypes';
import { SET_DELAYED_LOAD_OF_AVATAR_URL, SET_JWT, SET_KNOWN_AVATAR_URL } from './actionTypes';
/**
* Sets an avatar URL for delayed loading.
*
* @param {string} avatarUrl - The avatar URL to set for delayed loading.
* @returns {{
* type: SET_DELAYED_LOAD_OF_AVATAR_URL,
* avatarUrl: string
* }}
*/
export function setDelayedLoadOfAvatarUrl(avatarUrl?: string) {
return {
type: SET_DELAYED_LOAD_OF_AVATAR_URL,
avatarUrl
};
}
/**
* Stores a specific JSON Web Token (JWT) into the redux store.
*
* @param {string} [jwt] - The JSON Web Token (JWT) to store.
* @returns {{
* type: SET_TOKEN_DATA,
* type: SET_JWT,
* jwt: (string|undefined)
* }}
*/
@@ -15,3 +31,19 @@ export function setJWT(jwt?: string) {
jwt
};
}
/**
* Sets a known avatar URL.
*
* @param {string} avatarUrl - The avatar URL to set as known.
* @returns {{
* type: SET_KNOWN_AVATAR_URL,
* avatarUrl: string
* }}
*/
export function setKnownAvatarUrl(avatarUrl: string) {
return {
type: SET_KNOWN_AVATAR_URL,
avatarUrl
};
}

View File

@@ -7,6 +7,7 @@ export const MEET_FEATURES: Record<string, ParticipantFeaturesKey> = {
BRANDING: 'branding',
CALENDAR: 'calendar',
CREATE_POLLS: 'create-polls',
FILE_UPLOAD: 'file-upload',
FLIP: 'flip',
INBOUND_CALL: 'inbound-call',
LIVESTREAMING: 'livestreaming',
@@ -17,6 +18,7 @@ export const MEET_FEATURES: Record<string, ParticipantFeaturesKey> = {
ROOM: 'room',
SCREEN_SHARING: 'screen-sharing',
SEND_GROUPCHAT: 'send-groupchat',
LIST_VISITORS: 'list-visitors',
SIP_INBOUND_CALL: 'sip-inbound-call',
SIP_OUTBOUND_CALL: 'sip-outbound-call',
TRANSCRIPTION: 'transcription'

View File

@@ -4,19 +4,33 @@ import { AnyAction } from 'redux';
import { IStore } from '../../app/types';
import { isVpaasMeeting } from '../../jaas/functions';
import { getCurrentConference } from '../conference/functions';
import { SET_CONFIG } from '../config/actionTypes';
import { SET_LOCATION_URL } from '../connection/actionTypes';
import { CONNECTION_ESTABLISHED, SET_LOCATION_URL } from '../connection/actionTypes';
import { participantUpdated } from '../participants/actions';
import { getLocalParticipant } from '../participants/functions';
import { IParticipant } from '../participants/types';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import StateListenerRegistry from '../redux/StateListenerRegistry';
import { parseURIString } from '../util/uri';
import { SET_JWT } from './actionTypes';
import { setJWT } from './actions';
import { setDelayedLoadOfAvatarUrl, setJWT, setKnownAvatarUrl } from './actions';
import { parseJWTFromURLParams } from './functions';
import logger from './logger';
/**
* Set up a state change listener to perform maintenance tasks when the conference
* is left or failed, e.g. Clear any delayed load avatar url.
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
(conference, { dispatch }, previousConference): void => {
if (conference !== previousConference) {
dispatch(setDelayedLoadOfAvatarUrl());
}
});
/**
* Middleware to parse token data upon setting a new room URL.
*
@@ -31,7 +45,18 @@ MiddlewareRegistry.register(store => next => action => {
// XXX The JSON Web Token (JWT) is not the only piece of state that we
// have decided to store in the feature jwt
return _setConfigOrLocationURL(store, next, action);
case CONNECTION_ESTABLISHED: {
const state = store.getState();
const delayedLoadOfAvatarUrl = state['features/base/jwt'].delayedLoadOfAvatarUrl;
if (delayedLoadOfAvatarUrl) {
_overwriteLocalParticipant(store, {
avatarURL: delayedLoadOfAvatarUrl
});
store.dispatch(setDelayedLoadOfAvatarUrl());
store.dispatch(setKnownAvatarUrl(delayedLoadOfAvatarUrl));
}
}
case SET_JWT:
return _setJWT(store, next, action);
}
@@ -140,6 +165,7 @@ function _setJWT(store: IStore, next: Function, action: AnyAction) {
if (jwtPayload) {
const { context, iss, sub } = jwtPayload;
const { tokenGetUserInfoOutOfContext, tokenRespectTenant } = state['features/base/config'];
action.jwt = jwt;
action.issuer = iss;
@@ -155,7 +181,6 @@ function _setJWT(store: IStore, next: Function, action: AnyAction) {
const newUser = user ? { ...user } : {};
let features = context.features;
const { tokenRespectTenant } = state['features/base/config'];
// eslint-disable-next-line max-depth
if (!isVpaasMeeting(state) && tokenRespectTenant && context.tenant) {
@@ -167,6 +192,16 @@ function _setJWT(store: IStore, next: Function, action: AnyAction) {
features = context.tenant === tenant || tenant === '' ? features : {};
}
if (newUser.avatarURL) {
const { knownAvatarUrl } = state['features/base/jwt'];
if (knownAvatarUrl !== newUser.avatarURL) {
store.dispatch(setDelayedLoadOfAvatarUrl(newUser.avatarURL));
newUser.avatarURL = undefined;
}
}
_overwriteLocalParticipant(
store, { ...newUser,
features });
@@ -175,7 +210,8 @@ function _setJWT(store: IStore, next: Function, action: AnyAction) {
if (context.user && context.user.role === 'visitor') {
action.preferVisitor = true;
}
} else if (jwtPayload.name || jwtPayload.picture || jwtPayload.email) {
} else if (tokenGetUserInfoOutOfContext
&& (jwtPayload.name || jwtPayload.picture || jwtPayload.email)) {
// there are some tokens (firebase) having picture and name on the main level.
_overwriteLocalParticipant(store, {
avatarURL: jwtPayload.picture,

View File

@@ -1,14 +1,18 @@
import PersistenceRegistry from '../redux/PersistenceRegistry';
import ReducerRegistry from '../redux/ReducerRegistry';
import { equals } from '../redux/functions';
import { SET_JWT } from './actionTypes';
import { SET_DELAYED_LOAD_OF_AVATAR_URL, SET_JWT, SET_KNOWN_AVATAR_URL } from './actionTypes';
import logger from './logger';
export interface IJwtState {
callee?: {
name: string;
};
delayedLoadOfAvatarUrl?: string;
group?: string;
jwt?: string;
knownAvatarUrl?: string;
server?: string;
tenant?: string;
user?: {
@@ -17,6 +21,10 @@ export interface IJwtState {
};
}
PersistenceRegistry.register('features/base/jwt', {
knownAvatarUrl: true
});
/**
* Reduces redux actions which affect the JSON Web Token (JWT) stored in the
* redux store.
@@ -30,15 +38,35 @@ ReducerRegistry.register<IJwtState>(
'features/base/jwt',
(state = {}, action): IJwtState => {
switch (action.type) {
case SET_DELAYED_LOAD_OF_AVATAR_URL: {
const nextState = {
...state,
delayedLoadOfAvatarUrl: action.avatarUrl
};
if (equals(state, nextState)) {
return state;
}
logger.info('JWT avatarURL temporarily not loaded till jwt is verified on connect');
return nextState;
}
case SET_JWT: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { type, ...payload } = action;
const nextState = {
...state,
...payload
};
return equals(state, nextState) ? state : nextState;
}
case SET_KNOWN_AVATAR_URL:
return {
...state,
knownAvatarUrl: action.avatarUrl
};
}
return state;

View File

@@ -1,7 +1,10 @@
import NetInfo from '@react-native-community/netinfo';
import type { NetInfoState, NetInfoSubscription } from '@react-native-community/netinfo';
import {
default as NetInfo,
type NetInfoState,
type NetInfoSubscription
} from '@react-native-community/netinfo';
// eslint-disable-next-line lines-around-comment
// @ts-expect-error
// @ts-ignore
import EventEmitter from 'events';
import { ONLINE_STATE_CHANGED_EVENT } from './events';

View File

@@ -5,6 +5,7 @@ import { IReduxState, IStore } from '../../app/types';
import { isStageFilmstripAvailable } from '../../filmstrip/functions';
import { isAddPeopleEnabled, isDialOutEnabled } from '../../invite/functions';
import { toggleShareDialog } from '../../share-room/actions';
import { iAmVisitor } from '../../visitors/functions';
import { IStateful } from '../app/types';
import { GRAVATAR_BASE_URL } from '../avatar/constants';
import { isCORSAvatarURL } from '../avatar/functions';
@@ -413,6 +414,22 @@ export function getParticipantCountWithFake(stateful: IStateful) {
return remote.size + (local ? 1 : 0) + (localScreenShare ? 1 : 0);
}
/**
* Returns a count of the known participants in the passed in redux state,
* including fake participants. Subtract 1 when the local participant is a visitor as we do not show a local thumbnail.
* The number used to display the participant count in the UI.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/participants.
* @returns {number}
*/
export function getParticipantCountForDisplay(stateful: IStateful) {
const _iAmVisitor = iAmVisitor(stateful);
return getParticipantCount(stateful) - (_iAmVisitor ? 1 : 0);
}
/**
* Returns participant's display name.
*
@@ -806,3 +823,25 @@ export const setShareDialogVisiblity = (addPeopleFeatureEnabled: boolean, dispat
dispatch(toggleShareDialog(true));
}
};
/**
* Checks if private chat is enabled for the given participant.
*
* @param {IParticipant|undefined} participant - The participant to check.
* @param {IReduxState} state - The Redux state.
* @returns {boolean} - True if private chat is enabled, false otherwise.
*/
export function isPrivateChatEnabled(participant: IParticipant | undefined, state: IReduxState) {
const { remoteVideoMenu = {} } = state['features/base/config'];
const { disablePrivateChat } = remoteVideoMenu;
if (participant?.local || state['features/visitors'].iAmVisitor || disablePrivateChat === 'all') {
return false;
}
if (disablePrivateChat === 'allow-moderator-chat') {
return isLocalParticipantModerator(state) || isParticipantModerator(participant);
}
return !disablePrivateChat;
}

View File

@@ -58,8 +58,10 @@ export interface IParticipantFeatures {
'branding'?: boolean | string;
'calendar'?: boolean | string;
'create-polls'?: boolean | string;
'file-upload'?: boolean | string;
'flip'?: boolean | string;
'inbound-call'?: boolean | string;
'list-visitors'?: boolean | string;
'livestreaming'?: boolean | string;
'lobby'?: boolean | string;
'moderation'?: boolean | string;

View File

@@ -54,7 +54,7 @@ class Message extends Component<IProps> {
// Tokenize the text in order to avoid emoji substitution for URLs
const tokens = text ? text.split(' ') : [];
const content = [];
const content: any[] = [];
const { gifEnabled } = this.props;
// check if the message is a GIF
@@ -72,7 +72,11 @@ class Message extends Component<IProps> {
// Bypass the emojification when urls or matrix ids are involved
content.push(token);
} else {
content.push(...toArray(token, { className: 'smiley' }));
const emojified = [ ...toArray(token, { className: 'smiley' }) ];
content.push(
...emojified.some(item => typeof item === 'string') ? [ token ] : emojified
);
}
content.push(' ');

View File

@@ -1,5 +1,4 @@
import { Action, combineReducers } from 'redux';
import type { Reducer } from 'redux';
import { Action, type Reducer, combineReducers } from 'redux';
/**
* The type of the dictionary/map which associates a reducer (function) with the

View File

@@ -1,6 +1,7 @@
import { IStateful } from '../app/types';
import { isMobileBrowser } from '../environment/utils';
import { toState } from '../redux/functions';
import { SMALL_DESKTOP_WIDTH } from './constants';
/**

View File

@@ -4,8 +4,10 @@ import Icon from '../../icons/components/Icon';
import Tooltip from '../../tooltip/components/Tooltip';
import ContextMenuItem from '../../ui/components/web/ContextMenuItem';
import AbstractToolboxItem from './AbstractToolboxItem';
import type { IProps as AbstractToolboxItemProps } from './AbstractToolboxItem';
import {
default as AbstractToolboxItem,
type IProps as AbstractToolboxItemProps
} from './AbstractToolboxItem';
interface IProps extends AbstractToolboxItemProps {

View File

@@ -421,12 +421,14 @@ export function trackAdded(track: any) {
}
track.on(JitsiTrackEvents.LOCAL_TRACK_STOPPED,
() => dispatch({
type: TRACK_STOPPED,
track: {
jitsiTrack: track
}
}));
() => {
logger.debug(`Local track stopped: ${track}, removing it from the conference`);
dispatch({
type: TRACK_STOPPED,
track: {
jitsiTrack: track
} });
});
} else {
participantId = track.getParticipantId();
isReceivingData = true;

View File

@@ -2,7 +2,6 @@ import { batch } from 'react-redux';
import { IStore } from '../../app/types';
import { _RESET_BREAKOUT_ROOMS } from '../../breakout-rooms/actionTypes';
import { isPrejoinPageVisible } from '../../prejoin/functions';
import { getCurrentConference } from '../conference/functions';
import {
SET_AUDIO_MUTED,
@@ -203,11 +202,8 @@ function _setMuted(store: IStore, { ensureTrack, muted }: {
setTrackMuted(jitsiTrack, muted, state, dispatch)
.catch(() => dispatch(trackMuteUnmuteFailed(localTrack, muted)));
}
} else if (!muted && ensureTrack && (typeof APP === 'undefined' || isPrejoinPageVisible(state))) {
} else if (!muted && ensureTrack) {
typeof APP !== 'undefined' && dispatch(gumPending([ mediaType ], IGUMPendingState.PENDING_UNMUTE));
// FIXME: This only runs on mobile now because web has its own way of
// creating local tracks. Adjust the check once they are unified.
dispatch(createLocalTracksA({ devices: [ mediaType ] })).then(() => {
typeof APP !== 'undefined' && dispatch(gumPending([ mediaType ], IGUMPendingState.NONE));
});

View File

@@ -7,6 +7,7 @@ import Icon from '../../../icons/components/Icon';
import { IconCloseCircle } from '../../../icons/svg';
import { withPixelLineHeight } from '../../../styles/functions.web';
import { IInputProps } from '../types';
import { HiddenDescription } from './HiddenDescription';
interface IProps extends IInputProps {

View File

@@ -1,4 +1,4 @@
import { merge } from 'lodash';
import { merge } from 'lodash-es';
import * as jitsiTokens from './jitsiTokens.json';
import * as tokens from './tokens.json';

View File

@@ -12,13 +12,7 @@ const JITSI_MEET_APPS = [
'org.jitsi.meet',
// Android debug app.
'org.jitsi.meet.debug',
// 8x8 Work (Android).
'org.vom8x8.sipua',
// 8x8 Work (iOS).
'com.yourcompany.Virtual-Office'
'org.jitsi.meet.debug'
];
/**

View File

@@ -1,6 +1,7 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { Platform, View, ViewStyle } from 'react-native';
import { Platform, TextStyle, View, ViewStyle } from 'react-native';
import { Text } from 'react-native-paper';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
@@ -10,9 +11,9 @@ import { ASPECT_RATIO_WIDE } from '../../../base/responsive-ui/constants';
import IconButton from '../../../base/ui/components/native/IconButton';
import Input from '../../../base/ui/components/native/Input';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { isSendGroupChatDisabled } from '../../functions';
import styles from './styles';
import { isSendGroupChatDisabled } from '../../functions';
interface IProps extends WithTranslation {
@@ -92,6 +93,18 @@ class ChatInputBar extends Component<IProps, IState> {
inputBarStyles = styles.inputBarNarrow;
}
if (this.props._isSendGroupChatDisabled && !this.props._privateMessageRecipientId) {
return (
<View
id = 'no-messages-message'
style = { styles.disabledSendWrapper as ViewStyle }>
<Text style = { styles.emptyComponentText as TextStyle }>
{ this.props.t('chat.disabled') }
</Text>
</View>
);
}
return (
<View
id = 'chat-input'
@@ -112,8 +125,7 @@ class ChatInputBar extends Component<IProps, IState> {
returnKeyType = 'send'
value = { this.state.message } />
<IconButton
disabled = { !this.state.message
|| (this.props._isSendGroupChatDisabled && !this.props._privateMessageRecipientId) }
disabled = { !this.state.message }
id = { this.props.t('chat.sendButton') }
onPress = { this._onSubmit }
src = { IconSend }

View File

@@ -38,6 +38,11 @@ export default {
flex: 1
},
chatDisabled: {
padding: BaseTheme.spacing[2],
textAlign: 'center'
},
emptyComponentText: {
color: BaseTheme.palette.text03,
textAlign: 'center'
@@ -115,6 +120,15 @@ export default {
maxWidth: '80%'
},
disabledSendWrapper: {
alignSelf: 'center',
flex: 0,
padding: BoxModel.padding,
paddingBottom: '8%',
paddingTop: '8%',
maxWidth: '80%'
},
/**
* A special padding to avoid issues on some devices (such as Android devices with custom suggestions bar).
*/

View File

@@ -1,10 +1,11 @@
import { throttle } from 'lodash-es';
import React, { useCallback, useEffect, useState } from 'react';
import { connect, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { throttle } from 'lodash-es';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconInfo, IconMessage, IconShareDoc, IconSubtitles } from '../../../base/icons/svg';
import { getLocalParticipant } from '../../../base/participants/functions';
import Tabs from '../../../base/ui/components/web/Tabs';
import { arePollsDisabled } from '../../../conference/functions.any';
@@ -12,10 +13,10 @@ import FileSharing from '../../../file-sharing/components/web/FileSharing';
import { isFileSharingEnabled } from '../../../file-sharing/functions.any';
import PollsPane from '../../../polls/components/web/PollsPane';
import { isCCTabEnabled } from '../../../subtitles/functions.any';
import { setChatIsResizing, setUserChatWidth, sendMessage, setFocusedTab, toggleChat } from '../../actions.web';
import { sendMessage, setChatIsResizing, setFocusedTab, setUserChatWidth, toggleChat } from '../../actions.web';
import { CHAT_SIZE, ChatTabs, SMALL_WIDTH_THRESHOLD } from '../../constants';
import { getChatMaxSize } from '../../functions';
import { IChatProps as AbstractProps } from '../../types';
import { IconMessage, IconInfo, IconSubtitles, IconShareDoc } from '../../../base/icons/svg';
import ChatHeader from './ChatHeader';
import ChatInput from './ChatInput';
@@ -24,7 +25,7 @@ import DisplayNameForm from './DisplayNameForm';
import KeyboardAvoider from './KeyboardAvoider';
import MessageContainer from './MessageContainer';
import MessageRecipient from './MessageRecipient';
import { getChatMaxSize } from '../../functions';
interface IProps extends AbstractProps {
@@ -149,6 +150,7 @@ const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme,
boxSizing: 'border-box',
color: theme.palette.text01,
...theme.typography.heading6,
lineHeight: 'unset',
fontWeight: theme.typography.heading6.fontWeight as any,
'.jitsi-icon': {
@@ -445,7 +447,7 @@ const Chat = ({
* @returns {ReactElement}
*/
function renderTabs() {
const tabs = [
let tabs = [
{
accessibilityLabel: t('chat.tabs.chat'),
countBadge:
@@ -486,6 +488,10 @@ const Chat = ({
});
}
if (tabs.length === 1) {
tabs = [];
}
return (
<Tabs
accessibilityLabel = { _isPollsEnabled || _isCCTabEnabled || _isFileSharingTabEnabled

View File

@@ -2,12 +2,12 @@ import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import Icon from '../../../base/icons/components/Icon';
import { IconCloseLarge } from '../../../base/icons/svg';
import { toggleChat } from '../../actions.web';
import { IReduxState } from '../../../app/types';
import { ChatTabs } from '../../constants';
import { isFileSharingEnabled } from '../../../file-sharing/functions.any';
import { toggleChat } from '../../actions.web';
import { ChatTabs } from '../../constants';
interface IProps {

View File

@@ -10,10 +10,10 @@ import { translate } from '../../../base/i18n/functions';
import { IconFaceSmile, IconSend } from '../../../base/icons/svg';
import Button from '../../../base/ui/components/web/Button';
import Input from '../../../base/ui/components/web/Input';
import { CHAT_SIZE } from '../../constants';
import { areSmileysDisabled, isSendGroupChatDisabled } from '../../functions';
import SmileysPanel from './SmileysPanel';
import { CHAT_SIZE } from '../../constants';
const styles = (_theme: Theme, { _chatWidth }: IProps) => {
@@ -35,6 +35,12 @@ const styles = (_theme: Theme, { _chatWidth }: IProps) => {
backgroundColor: '#131519',
borderTop: '1px solid #A4B8D1'
}
},
chatDisabled: {
borderTop: `1px solid ${_theme.palette.ui02}`,
boxSizing: 'border-box' as const,
padding: _theme.spacing(4),
textAlign: 'center' as const,
}
};
};
@@ -159,7 +165,15 @@ class ChatInput extends Component<IProps, IState> {
*/
override render() {
const classes = withStyles.getClasses(this.props);
const hideInput = this.props._isSendGroupChatDisabled && !this.props._privateMessageRecipientId;
if (hideInput) {
return (
<div className = { classes.chatDisabled }>
{this.props.t('chat.disabled')}
</div>
);
}
return (
<div className = { `chat-input-container${this.state.message.trim().length ? ' populated' : ''}` }>
@@ -188,8 +202,7 @@ class ChatInput extends Component<IProps, IState> {
value = { this.state.message } />
<Button
accessibilityLabel = { this.props.t('chat.sendButton') }
disabled = { !this.state.message.trim()
|| (this.props._isSendGroupChatDisabled && !this.props._privateMessageRecipientId) }
disabled = { !this.state.message.trim() }
icon = { IconSend }
onClick = { this._onSubmitMessage }
size = { isMobileBrowser() ? 'large' : 'medium' } />

View File

@@ -5,7 +5,7 @@ import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { getParticipantDisplayName } from '../../../base/participants/functions';
import { getParticipantById, getParticipantDisplayName, isPrivateChatEnabled } from '../../../base/participants/functions';
import Popover from '../../../base/popover/components/Popover.web';
import Message from '../../../base/react/components/web/Message';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
@@ -412,10 +412,12 @@ const ChatMessage = ({
*/
function _mapStateToProps(state: IReduxState, { message }: IProps) {
const { knocking } = state['features/lobby'];
const localParticipantId = state['features/base/participants'].local?.id;
const participant = getParticipantById(state, message.participantId);
const enablePrivateChat = isPrivateChatEnabled(participant, state);
return {
shouldDisplayChatMessageMenu: message.participantId !== localParticipantId,
shouldDisplayChatMessageMenu: enablePrivateChat,
knocking,
state
};

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import Icon from '../../../base/icons/components/Icon';
import { IconSubtitles } from '../../../base/icons/svg';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
@@ -15,7 +16,6 @@ import { ISubtitle } from '../../../subtitles/types';
import { isTranscribing } from '../../../transcribing/functions';
import { SubtitlesMessagesContainer } from './SubtitlesMessagesContainer';
import { IReduxState } from '../../../app/types';
/**
* The styles for the ClosedCaptionsTab component.

View File

@@ -11,11 +11,11 @@ import { MEET_FEATURES } from '../base/jwt/constants';
import { isJwtFeatureEnabled } from '../base/jwt/functions';
import { getParticipantById } from '../base/participants/functions';
import { escapeRegexp } from '../base/util/helpers';
import { getParticipantsPaneWidth } from '../participants-pane/functions';
import { VIDEO_SPACE_MIN_SIZE } from '../video-layout/constants';
import { MESSAGE_TYPE_ERROR, MESSAGE_TYPE_LOCAL, TIMESTAMP_FORMAT } from './constants';
import { IMessage } from './types';
import { getParticipantsPaneWidth } from '../participants-pane/functions';
import { VIDEO_SPACE_MIN_SIZE } from '../video-layout/constants';
/**
* An ASCII emoticon regexp array to find and replace old-style ASCII

View File

@@ -1,6 +1,6 @@
import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes';
import { ILocalParticipant, IParticipant } from '../base/participants/types';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import { ChatTabs } from './constants';
import {
ADD_MESSAGE,
@@ -18,9 +18,8 @@ import {
SET_PRIVATE_MESSAGE_RECIPIENT,
SET_USER_CHAT_WIDTH
} from './actionTypes';
import { CHAT_SIZE } from './constants';
import { CHAT_SIZE, ChatTabs } from './constants';
import { IMessage } from './types';
import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes';
const DEFAULT_STATE = {
groupChatWithPermissions: false,

View File

@@ -2,6 +2,7 @@
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { clientResized } from '../base/responsive-ui/actions';
import { setChatWidth } from './actions.web';
import { CHAT_SIZE } from './constants';
import { getChatMaxSize } from './functions';

View File

@@ -17,9 +17,9 @@ import { translate } from '../../base/i18n/functions';
import Icon from '../../base/icons/components/Icon';
import { IconCloseLarge } from '../../base/icons/svg';
import { browser } from '../../base/lib-jitsi-meet';
import { isNarrowScreenWithChatOpen } from '../../base/responsive-ui/functions';
import { isVpaasMeeting } from '../../jaas/functions';
import logger from '../logger';
import { isNarrowScreenWithChatOpen } from '../../base/responsive-ui/functions';
const emptyObject = {};

View File

@@ -46,9 +46,9 @@ import Toolbox from '../../../toolbox/components/native/Toolbox';
import { isToolboxVisible } from '../../../toolbox/functions.native';
import {
AbstractConference,
type AbstractProps,
abstractMapStateToProps
} from '../AbstractConference';
import type { AbstractProps } from '../AbstractConference';
import { isConnecting } from '../functions.native';
import AlwaysOnLabels from './AlwaysOnLabels';

View File

@@ -1,7 +1,7 @@
import { throttle } from 'lodash-es';
import React from 'react';
import React, { useCallback, useState } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect as reactReduxConnect } from 'react-redux';
import { connect as reactReduxConnect, useDispatch, useSelector, useStore } from 'react-redux';
// @ts-expect-error
import VideoLayout from '../../../../../modules/UI/videolayout/VideoLayout';
@@ -11,7 +11,10 @@ import { hangup } from '../../../base/connection/actions.web';
import { isMobileBrowser } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n/functions';
import { setColorAlpha } from '../../../base/util/helpers';
import { openChat, setFocusedTab } from '../../../chat/actions.web';
import Chat from '../../../chat/components/web/Chat';
import { ChatTabs } from '../../../chat/constants';
import { isFileUploadingEnabled, processFiles } from '../../../file-sharing/functions.any';
import MainFilmstrip from '../../../filmstrip/components/web/MainFilmstrip';
import ScreenshareFilmstrip from '../../../filmstrip/components/web/ScreenshareFilmstrip';
import StageFilmstrip from '../../../filmstrip/components/web/StageFilmstrip';
@@ -22,7 +25,7 @@ import { getIsLobbyVisible } from '../../../lobby/functions';
import { getOverlayToRender } from '../../../overlay/functions.web';
import ParticipantsPane from '../../../participants-pane/components/web/ParticipantsPane';
import Prejoin from '../../../prejoin/components/web/Prejoin';
import { isPrejoinPageVisible } from '../../../prejoin/functions';
import { isPrejoinPageVisible } from '../../../prejoin/functions.web';
import ReactionAnimations from '../../../reactions/components/web/ReactionsAnimations';
import { toggleToolboxVisible } from '../../../toolbox/actions.any';
import { fullScreenChanged, showToolbox } from '../../../toolbox/actions.web';
@@ -36,9 +39,9 @@ import { init } from '../../actions.web';
import { maybeShowSuboptimalExperienceNotification } from '../../functions.web';
import {
AbstractConference,
type AbstractProps,
abstractMapStateToProps
} from '../AbstractConference';
import type { AbstractProps } from '../AbstractConference';
import ConferenceInfo from './ConferenceInfo';
import { default as Notice } from './Notice';
@@ -430,4 +433,65 @@ function _mapStateToProps(state: IReduxState) {
};
}
export default reactReduxConnect(_mapStateToProps)(translate(Conference));
export default reactReduxConnect(_mapStateToProps)(translate(props => {
const dispatch = useDispatch();
const store = useStore();
const [ isDragging, setIsDragging ] = useState(false);
const { isOpen: isChatOpen } = useSelector((state: IReduxState) => state['features/chat']);
const isFileUploadEnabled = useSelector(isFileUploadingEnabled);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isFileUploadEnabled) {
return;
}
if (isDragging) {
if (!isChatOpen) {
dispatch(openChat());
}
dispatch(setFocusedTab(ChatTabs.FILE_SHARING));
}
}, [ isChatOpen, isDragging, isFileUploadEnabled ]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (!isFileUploadEnabled) {
return;
}
if (e.dataTransfer.files?.length > 0) {
processFiles(e.dataTransfer.files, store);
}
}, [ isFileUploadEnabled, processFiles ]);
return (
<div
onDragEnter = { handleDragEnter }
onDragLeave = { handleDragLeave }
onDragOver = { handleDragOver }
onDrop = { handleDrop }>
{/* @ts-ignore */}
<Conference { ...props } />
</div>
);
}));

View File

@@ -7,11 +7,10 @@ import { openDialog } from '../../../base/dialog/actions';
import { IconUsers } from '../../../base/icons/svg';
import Label from '../../../base/label/components/web/Label';
import { COLORS } from '../../../base/label/constants';
import { getParticipantCount } from '../../../base/participants/functions';
import { getParticipantCountForDisplay } from '../../../base/participants/functions';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import SpeakerStats from '../../../speaker-stats/components/web/SpeakerStats';
import { isSpeakerStatsDisabled } from '../../../speaker-stats/functions';
import { iAmVisitor } from '../../../visitors/functions';
/**
* ParticipantsCount react component.
@@ -21,17 +20,11 @@ import { iAmVisitor } from '../../../visitors/functions';
*/
function SpeakerStatsLabel() {
const conference = useSelector((state: IReduxState) => state['features/base/conference'].conference);
let count = useSelector(getParticipantCount);
const iAmVisitorState = useSelector(iAmVisitor);
const count = useSelector(getParticipantCountForDisplay);
const _isSpeakerStatsDisabled = useSelector(isSpeakerStatsDisabled);
const dispatch = useDispatch();
const { t } = useTranslation();
// visitor has hidden its own video and should not count itself
if (iAmVisitorState) {
count--;
}
const onClick = () => {
dispatch(openDialog(SpeakerStats, { conference }));
};

View File

@@ -106,29 +106,10 @@ const DeepLinkingMobilePage: React.FC<WithTranslation> = ({ t }) => {
const { classes: styles } = useStyles();
const generateDownloadURL = useCallback(() => {
const { downloadLink, dynamicLink, appScheme }
const { downloadLink }
= (deeplinkingCfg?.[Platform.OS as keyof typeof deeplinkingCfg] || {}) as IDeeplinkingMobileConfig;
if (downloadLink && typeof dynamicLink === 'undefined') {
return downloadLink;
}
const {
apn,
appCode,
customDomain,
ibi,
isi
} = dynamicLink || {};
const domain = customDomain ?? `https://${appCode}.app.goo.gl`;
return `${domain}/?link=${
encodeURIComponent(window.location.href)}&apn=${
apn}&ibi=${
ibi}&isi=${
isi}&ius=${
appScheme}&efr=1`;
return downloadLink;
}, [ deeplinkingCfg ]);
const onDownloadApp = useCallback(() => {

View File

@@ -1,14 +1,14 @@
import { IStateful } from '../base/app/types';
import { getCurrentConference } from '../base/conference/functions';
import { PARTICIPANT_ROLE_CHANGED } from '../base/participants/actionTypes';
import { PARTICIPANT_ROLE } from '../base/participants/constants';
import { getLocalParticipant, isLocalParticipantModerator } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { toState } from '../base/redux/functions';
import { SET_DYNAMIC_BRANDING_DATA, SET_DYNAMIC_BRANDING_READY } from './actionTypes';
import { fetchCustomIcons } from './functions.any';
import logger from './logger';
import { getCurrentConference } from '../base/conference/functions';
import { getLocalParticipant, isLocalParticipantModerator } from '../base/participants/functions';
import { PARTICIPANT_ROLE_CHANGED } from '../base/participants/actionTypes';
import { PARTICIPANT_ROLE } from '../base/participants/constants';
import { IStateful } from '../base/app/types';
import { toState } from '../base/redux/functions';
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {

View File

@@ -17,45 +17,7 @@ MiddlewareRegistry.register(store => next => action => {
}
case SET_DYNAMIC_BRANDING_DATA: {
const {
avatarBackgrounds = [],
backgroundColor,
backgroundImageUrl,
brandedIcons,
customParticipantMenuButtons,
customToolbarButtons,
didPageUrl,
downloadAppsUrl,
etherpadBase,
inviteDomain,
labels,
liveStreamingDialogUrls,
peopleSearchUrl,
salesforceUrl,
sharedVideoAllowedURLDomains,
supportUrl,
userDocumentationUrl,
} = action.value;
action.value = {
avatarBackgrounds,
backgroundColor,
backgroundImageUrl,
brandedIcons,
customParticipantMenuButtons,
customToolbarButtons,
didPageUrl,
downloadAppsUrl,
etherpadBase,
inviteDomain,
labels,
liveStreamingDialogUrls,
peopleSearchUrl,
salesforceUrl,
sharedVideoAllowedURLDomains,
supportUrl,
userDocumentationUrl
};
const { avatarBackgrounds = [] } = action.value;
// The backend may send an empty string, make sure we skip that.
if (Array.isArray(avatarBackgrounds)) {

View File

@@ -30,7 +30,27 @@ export const UPLOAD_FILES = 'UPLOAD_FILES';
export const ADD_FILE = 'ADD_FILE';
/**
* The type of redux action to remove a file from the state.
* The type of redux action to add files to the state.
*
* {
* type: _FILE_LIST_RECEIVED,
* files: Array<IFileMetadata>
* }
*/
export const _FILE_LIST_RECEIVED = '_FILE_LIST_RECEIVED';
/**
* The type of redux action to remove a file from the state as it was removed from the backend.
*
* {
* type: _FILE_REMOVED,
* fileId: string
* }
*/
export const _FILE_REMOVED = '_FILE_REMOVED';
/**
* The type of redux action to remove a file from the backend.
*
* {
* type: REMOVE_FILE,

View File

@@ -1,9 +1,9 @@
import {
ADD_FILE,
DOWNLOAD_FILE,
REMOVE_FILE,
UPDATE_FILE_UPLOAD_PROGRESS,
UPLOAD_FILES,
DOWNLOAD_FILE
UPLOAD_FILES
} from './actionTypes';
import { IFileMetadata } from './types';
@@ -49,7 +49,7 @@ export function addFile(file: IFileMetadata) {
}
/**
* Remove a file.
* Remove a file from the backend.
*
* @param {string} fileId - The ID of the file to remove.
* @returns {Object}

View File

@@ -1,19 +1,24 @@
import React, { useCallback, useState, useRef } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch, useSelector, useStore } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import Avatar from '../../../base/avatar/components/Avatar';
import { IconCloudUpload, IconDownload, IconTrash } from '../../../base/icons/svg';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import Icon from '../../../base/icons/components/Icon';
import { isLocalParticipantModerator } from '../../../base/participants/functions';
import { IconCloudUpload, IconDownload, IconTrash } from '../../../base/icons/svg';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
import BaseTheme from '../../../base/ui/components/BaseTheme.web';
import { downloadFile, removeFile, uploadFiles } from '../../actions';
import { formatFileSize, formatTimestamp, getFileIcon } from '../../functions.any';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import { downloadFile, removeFile } from '../../actions';
import {
formatFileSize,
formatTimestamp,
getFileIcon,
isFileUploadingEnabled,
processFiles
} from '../../functions.any';
const useStyles = makeStyles()(theme => {
return {
@@ -51,7 +56,6 @@ const useStyles = makeStyles()(theme => {
position: 'absolute',
right: 0,
top: 0,
transition: 'opacity 0.15s ease-in-out',
zIndex: 0,
'&.dragging': {
@@ -205,9 +209,10 @@ const FileSharing = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const dispatch = useDispatch();
const store = useStore();
const { files } = useSelector((state: IReduxState) => state['features/file-sharing']);
const sortedFiles = Array.from(files.values()).sort((a, b) => a.fileName.localeCompare(b.fileName));
const isModerator = useSelector(isLocalParticipantModerator);
const isUploadEnabled = useSelector(isFileUploadingEnabled);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -226,15 +231,10 @@ const FileSharing = () => {
e.stopPropagation();
}, []);
const processFiles = useCallback((fileList: FileList | File[]) => {
const newFiles = Array.from(fileList);
dispatch(uploadFiles(newFiles));
}, [ dispatch ]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
processFiles(e.target.files);
processFiles(e.target.files as FileList, store);
e.target.value = ''; // Reset the input value to allow re-uploading the same file
}
}, [ processFiles ]);
@@ -243,8 +243,8 @@ const FileSharing = () => {
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
processFiles(e.dataTransfer.files);
if (e.dataTransfer.files?.length > 0) {
processFiles(e.dataTransfer.files as FileList, store);
}
}, [ processFiles ]);
@@ -261,7 +261,7 @@ const FileSharing = () => {
return (
<div className = { classes.container }>
{
isModerator && (
isUploadEnabled && (
<>
<div
className = { `${classes.dropZone} ${
@@ -347,7 +347,7 @@ const FileSharing = () => {
size = { 24 }
src = { IconDownload } />
{
isModerator && (
isUploadEnabled && (
<Icon
className = { `${classes.actionIcon} actionIconVisibility` }
color = { BaseTheme.palette.icon01 }
@@ -378,7 +378,7 @@ const FileSharing = () => {
)
}
{
isModerator && (
isUploadEnabled && (
<Button
accessibilityLabel = { t('fileSharing.uploadFile') }
className = { classes.uploadButton }

View File

@@ -1,4 +1,4 @@
/**
* The key for file sharing metadata in the conference.
* The max file size we accept for upload.
*/
export const FILE_SHARING_ID = 'files';
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB

View File

@@ -1,11 +1,19 @@
import { IReduxState } from '../app/types';
import { IReduxState, IStore } from '../app/types';
import { getLocalizedDateFormatter } from '../base/i18n/dateUtil';
import {
IconImage,
IconShareDoc,
IconVideo,
IconVolumeUp
} from '../base/icons/svg';
import { getLocalizedDateFormatter } from '../base/i18n/dateUtil';
import { MEET_FEATURES } from '../base/jwt/constants';
import { isJwtFeatureEnabled } from '../base/jwt/functions';
import { showErrorNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants';
import { iAmVisitor } from '../visitors/functions';
import { uploadFiles } from './actions';
import { MAX_FILE_SIZE } from './constants';
/**
* Checks whether file sharing feature is enabled.
@@ -92,3 +100,58 @@ export function formatTimestamp(timestamp: number): string {
return `${monthDay}\n${time}`;
}
/**
* Processes a list of files for upload.
*
* @param {FileList|File[]} fileList - The list of files to process.
* @param {Dispatch} dispatch - The Redux dispatch function.
* @returns {void}
*/
// @ts-ignore
export const processFiles = (fileList: FileList | File[], store: IStore) => {
const state = store.getState();
const dispatch = store.dispatch;
const { maxFileSize = MAX_FILE_SIZE } = state['features/base/config']?.fileSharing ?? {};
const newFiles = Array.from(fileList as File[]).filter((file: File) => {
// No file size limitation
if (maxFileSize === -1) {
return true;
}
// Check file size before upload
if (file.size > maxFileSize) {
dispatch(showErrorNotification({
titleKey: 'fileSharing.fileTooLargeTitle',
descriptionKey: 'fileSharing.fileTooLargeDescription',
descriptionArguments: {
maxFileSize: formatFileSize(maxFileSize)
},
appearance: NOTIFICATION_TYPE.ERROR
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
return false;
}
return true;
});
if (newFiles.length > 0) {
dispatch(uploadFiles(newFiles as File[]));
}
};
/**
* Determines if file uploading is enabled based on JWT feature flags and file sharing settings.
*
* @param {IReduxState} state - Current state.
* @returns {boolean} Indication of whether local user can upload files.
*/
export function isFileUploadingEnabled(state: IReduxState): boolean {
return !iAmVisitor(state)
&& isJwtFeatureEnabled(state, MEET_FEATURES.FILE_UPLOAD, false)
&& isFileSharingEnabled(state);
}

View File

@@ -1,17 +1,48 @@
import { v4 as uuidv4 } from 'uuid';
import { IStore } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
import { getLocalParticipant, getParticipantDisplayName } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { showErrorNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants';
import { DOWNLOAD_FILE, REMOVE_FILE, UPLOAD_FILES } from './actionTypes';
import { DOWNLOAD_FILE, REMOVE_FILE, UPLOAD_FILES, _FILE_LIST_RECEIVED, _FILE_REMOVED } from './actionTypes';
import { addFile, removeFile, updateFileProgress } from './actions';
import { getFileExtension } from './functions.any';
import logger from './logger';
import { FILE_SHARING_ID } from './constants';
import { IFileMetadata } from './types';
import { downloadFile } from './utils';
/**
* Registers a change handler for state['features/base/conference'].conference to
* set the event listeners needed for the file sharing feature to operate.
*/
StateListenerRegistry.register(
state => state['features/base/conference'].conference,
(conference, { dispatch }, previousConference) => {
if (conference && !previousConference) {
conference.on(JitsiConferenceEvents.FILE_SHARING_FILE_ADDED, (file: IFileMetadata) => {
dispatch(addFile(file));
});
conference.on(JitsiConferenceEvents.FILE_SHARING_FILE_REMOVED, (fileId: string) => {
dispatch({
type: _FILE_REMOVED,
fileId
});
});
conference.on(JitsiConferenceEvents.FILE_SHARING_FILES_RECEIVED, (files: object) => {
dispatch({
type: _FILE_LIST_RECEIVED,
files
});
});
}
});
/**
* Middleware that handles file sharing actions.
@@ -24,93 +55,12 @@ MiddlewareRegistry.register(store => next => action => {
case UPLOAD_FILES: {
const state = store.getState();
const conference = getCurrentConference(state);
const sessionId = conference?.getMeetingUniqueId();
const localParticipant = getLocalParticipant(state);
const { fileSharing } = state['features/base/config'];
const { connection } = state['features/base/connection'];
const { jwt } = state['features/base/jwt'];
const roomJid = conference?.room?.roomjid;
for (const file of action.files) {
const jid = connection!.getJid();
const fileId = uuidv4();
const fileMetadata: IFileMetadata = {
authorParticipantId: localParticipant!.id,
authorParticipantJid: jid,
authorParticipantName: getParticipantDisplayName(state, localParticipant!.id),
conferenceFullName: roomJid ?? '',
fileId,
fileName: file.name,
fileSize: file.size,
fileType: getFileExtension(file.name),
timestamp: Date.now()
};
store.dispatch(addFile(fileMetadata));
store.dispatch(updateFileProgress(fileId, 1));
// Upload file.
const formData = new FormData();
formData.append('metadata', JSON.stringify(fileMetadata));
// @ts-ignore
formData.append('file', file as Blob, file.name);
// Use XMLHttpRequest to track upload
const xhr = new XMLHttpRequest();
const handleError = () => {
logger.warn('Could not upload file:', xhr.statusText);
store.dispatch(removeFile(fileId));
store.dispatch(showErrorNotification({
titleKey: 'fileSharing.uploadFailedTitle',
descriptionKey: 'fileSharing.uploadFailedDescription',
appearance: NOTIFICATION_TYPE.ERROR
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
};
xhr.open('POST', `${fileSharing!.apiUrl!}/sessions/${sessionId}/files`);
xhr.responseType = 'json';
if (jwt) {
xhr.setRequestHeader('Authorization', `Bearer ${jwt}`);
conference?.getShortTermCredentials(conference?.getFileSharing()?.getIdentityType()).then((token: string) => {
for (const file of action.files) {
uploadFile(file, store, token);
}
xhr.upload.onprogress = event => {
if (event.lengthComputable) {
// We use 99% as the max value to avoid showing 100% before the
// upload is actually finished, that is, when the request is completed.
const percent = Math.max((event.loaded / event.total) * 100, 99);
store.dispatch(updateFileProgress(fileId, percent));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
store.dispatch(updateFileProgress(fileId, 100));
const metadataHandler = conference?.getMetadataHandler();
if (metadataHandler) {
const existingMetadata = metadataHandler.getMetadata()[FILE_SHARING_ID] ?? {};
metadataHandler.setMetadata(FILE_SHARING_ID, {
...existingMetadata,
[fileId]: fileMetadata
});
}
} else {
handleError();
}
};
xhr.onerror = handleError;
xhr.send(formData);
}
});
return next(action);
}
@@ -118,41 +68,41 @@ MiddlewareRegistry.register(store => next => action => {
case REMOVE_FILE: {
const state = store.getState();
const conference = getCurrentConference(state);
const { fileSharing } = state['features/base/config'];
const { jwt } = state['features/base/jwt'];
const sessionId = conference?.getMeetingUniqueId();
let doDelete = false;
const { files } = state['features/file-sharing'];
const fileId = action.fileId;
const existingMetadata = files.get(fileId);
// First remove the file metadata so others won't attempt to download it anymore.
const metadataHandler = conference?.getMetadataHandler();
if (metadataHandler) {
const existingMetadata = metadataHandler.getMetadata()[FILE_SHARING_ID] ?? {};
const fileMetadata = existingMetadata[action.fileId];
doDelete = (fileMetadata?.process ?? 100) === 100;
delete existingMetadata[action.fileId];
metadataHandler.setMetadata(FILE_SHARING_ID, existingMetadata);
}
if (!doDelete) {
// ignore remove a file till the file is actually uploaded
if (!conference || (existingMetadata?.progress ?? 100) !== 100) {
return next(action);
}
// First, remove the file metadata so others won't attempt to download it anymore.
conference.getFileSharing().removeFile(fileId);
// remove it from local state
store.dispatch({
type: _FILE_REMOVED,
fileId
});
const { fileSharing } = state['features/base/config'];
const sessionId = conference.getMeetingUniqueId();
// Now delete it from the server.
fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/files/${action.fileId}`, {
conference.getShortTermCredentials(conference.getFileSharing().getIdentityType())
.then((token: string) => fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/files/${fileId}`, {
method: 'DELETE',
headers: {
...jwt && { 'Authorization': `Bearer ${jwt}` }
'Authorization': `Bearer ${token}`
}
})
.then(response => {
}))
.then((response: { ok: any; statusText: any; }) => {
if (!response.ok) {
throw new Error(`Failed to delete file: ${response.statusText}`);
}
})
.catch(error => {
.catch((error: any) => {
logger.warn('Could not delete file:', error);
});
@@ -165,30 +115,31 @@ MiddlewareRegistry.register(store => next => action => {
const conference = getCurrentConference(state);
const sessionId = conference?.getMeetingUniqueId();
fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/document`, {
conference?.getShortTermCredentials(conference?.getFileSharing()?.getIdentityType())
.then((token: string) => fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/files/${action.fileId}`, {
method: 'GET',
headers: {
'X-File-Id': action.fileId,
'Authorization': `Bearer ${token}`
}
})
.then(response => response.json())
.then(data => {
const url = data.presignedUrl;
}))
.then((response: any) => response.json())
.then((data: { fileName: string; presignedUrl: string; }) => {
const { presignedUrl, fileName } = data;
if (!url) {
if (!presignedUrl) {
throw new Error('No presigned URL found in the response.');
}
window.open(url, '_blank', 'noreferrer,noopener');
return downloadFile(presignedUrl, fileName);
})
.catch(error => {
.catch((error: any) => {
logger.warn('Could not download file:', error);
store.dispatch(showErrorNotification({
titleKey: 'fileSharing.downloadFailedTitle',
descriptionKey: 'fileSharing.downloadFailedDescription',
appearance: NOTIFICATION_TYPE.ERROR
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
});
return next(action);
@@ -197,3 +148,93 @@ MiddlewareRegistry.register(store => next => action => {
return next(action);
});
/**
* Uploads a file to the server.
*
* @param {File} file - The file to upload.
* @param {IStore} store - The redux store.
* @param {string} token - The token to use for requests.
* @returns {void}
*/
function uploadFile(file: File, store: IStore, token: string): void {
const state = store.getState();
const conference = getCurrentConference(state);
const sessionId = conference?.getMeetingUniqueId();
const localParticipant = getLocalParticipant(state);
const { fileSharing } = state['features/base/config'];
const { connection } = state['features/base/connection'];
const roomJid = conference?.room?.roomjid;
const jid = connection!.getJid();
const fileId = uuidv4();
const fileMetadata: IFileMetadata = {
authorParticipantId: localParticipant!.id,
authorParticipantJid: jid,
authorParticipantName: getParticipantDisplayName(state, localParticipant!.id),
conferenceFullName: roomJid ?? '',
fileId,
fileName: file.name,
fileSize: file.size,
fileType: getFileExtension(file.name),
timestamp: Date.now()
};
store.dispatch(addFile(fileMetadata));
store.dispatch(updateFileProgress(fileId, 1));
// Upload file.
const formData = new FormData();
formData.append('metadata', JSON.stringify(fileMetadata));
// @ts-ignore
formData.append('file', file as Blob, file.name);
// Use XMLHttpRequest to track upload
const xhr = new XMLHttpRequest();
const handleError = () => {
logger.warn('Could not upload file:', xhr.statusText);
store.dispatch(removeFile(fileId));
store.dispatch(showErrorNotification({
titleKey: 'fileSharing.uploadFailedTitle',
descriptionKey: 'fileSharing.uploadFailedDescription',
appearance: NOTIFICATION_TYPE.ERROR
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
};
xhr.open('POST', `${fileSharing!.apiUrl!}/sessions/${sessionId}/files`);
xhr.responseType = 'json';
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
xhr.upload.onprogress = event => {
if (event.lengthComputable) {
// We use 99% as the max value to avoid showing 100% before the
// upload is actually finished, that is, when the request is completed.
const percent = Math.min((event.loaded / event.total) * 100, 99);
store.dispatch(updateFileProgress(fileId, percent));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
store.dispatch(updateFileProgress(fileId, 100));
const fileSharingHandler = conference?.getFileSharing();
fileSharingHandler.addFile(fileMetadata);
} else {
handleError();
}
};
xhr.onerror = handleError;
xhr.send(formData);
}

View File

@@ -1,12 +1,6 @@
import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
ADD_FILE,
REMOVE_FILE,
UPDATE_FILE_UPLOAD_PROGRESS
} from './actionTypes';
import { FILE_SHARING_ID } from './constants';
import { ADD_FILE, UPDATE_FILE_UPLOAD_PROGRESS, _FILE_LIST_RECEIVED, _FILE_REMOVED } from './actionTypes';
import { IFileMetadata } from './types';
export interface IFileSharingState {
@@ -30,7 +24,7 @@ ReducerRegistry.register<IFileSharingState>('features/file-sharing',
};
}
case REMOVE_FILE: {
case _FILE_REMOVED: {
const newFiles = new Map(state.files);
newFiles.delete(action.fileId);
@@ -53,22 +47,10 @@ ReducerRegistry.register<IFileSharingState>('features/file-sharing',
};
}
case UPDATE_CONFERENCE_METADATA: {
const { metadata } = action;
const files = metadata?.[FILE_SHARING_ID];
if (files) {
const newFiles: Map<string, IFileMetadata> = new Map(Object.entries(files));
return {
files: new Map([
...state.files,
...newFiles
])
};
}
return state;
case _FILE_LIST_RECEIVED: {
return {
files: new Map(Object.entries(action.files))
};
}
default:

View File

@@ -0,0 +1,27 @@
const generateDownloadUrl = async (url: string) => {
const resp = await fetch(url);
const respBlob = await resp.blob();
const blob = new Blob([ respBlob ]);
return URL.createObjectURL(blob);
};
export const downloadFile = async (url: string, fileName: string) => {
const dowloadUrl = await generateDownloadUrl(url);
const link = document.createElement('a');
if (fileName) {
link.download = fileName;
}
link.href = dowloadUrl;
document.body.appendChild(link);
link.click();
link.remove();
// fix for certain browsers
setTimeout(() => {
URL.revokeObjectURL(dowloadUrl);
}, 0);
};

View File

@@ -1,3 +1,4 @@
import { Theme } from '@mui/material';
import clsx from 'clsx';
import { throttle } from 'lodash-es';
import React, { PureComponent } from 'react';
@@ -13,6 +14,7 @@ import { isMobileBrowser } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n/functions';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowDown, IconArrowUp } from '../../../base/icons/svg';
import { isNarrowScreenWithChatOpen } from '../../../base/responsive-ui/functions';
import { getHideSelfView } from '../../../base/settings/functions.any';
import { registerShortcut, unregisterShortcut } from '../../../keyboard-shortcuts/actions';
import { showToolbox } from '../../../toolbox/actions.web';
@@ -47,8 +49,6 @@ import {
import AudioTracksContainer from './AudioTracksContainer';
import Thumbnail from './Thumbnail';
import ThumbnailWrapper from './ThumbnailWrapper';
import { Theme } from '@mui/material';
import { isNarrowScreenWithChatOpen } from '../../../base/responsive-ui/functions';
const BACKGROUND_COLOR = 'rgba(51, 51, 51, .5)';
@@ -179,6 +179,9 @@ function styles(theme: Theme, props: IProps) {
'&.top-panel-filmstrip': {
flexDirection: 'column' as const
},
'&.always-show-resize-bar': {
backgroundColor: BACKGROUND_COLOR
}
},
@@ -245,6 +248,10 @@ function styles(theme: Theme, props: IProps) {
* The type of the React {@code Component} props of {@link Filmstrip}.
*/
export interface IProps extends WithTranslation {
/**
* Whether to always show the resize bar on filmstrip. This will make the filmstrip always visible.
*/
_alwaysShowResizeBar?: boolean;
/**
* Additional CSS class names top add to the root.
@@ -533,6 +540,7 @@ class Filmstrip extends PureComponent <IProps, IState> {
override render() {
const filmstripStyle: any = { };
const {
_alwaysShowResizeBar,
_currentLayout,
_disableSelfView,
_filmstripDisabled,
@@ -649,11 +657,12 @@ class Filmstrip extends PureComponent <IProps, IState> {
{_resizableFilmstrip
? <div
className = { clsx('resizable-filmstrip', classes.resizableFilmstripContainer,
_topPanelFilmstrip && 'top-panel-filmstrip') }>
_topPanelFilmstrip && 'top-panel-filmstrip',
_alwaysShowResizeBar && 'always-show-resize-bar') }>
<div
className = { clsx('dragHandleContainer',
classes.dragHandleContainer,
isMouseDown && 'visible',
(isMouseDown || _alwaysShowResizeBar) && 'visible',
_topPanelFilmstrip && 'top-panel')
}
onMouseDown = { this._onDragHandleMouseDown }>
@@ -1081,7 +1090,7 @@ class Filmstrip extends PureComponent <IProps, IState> {
function _mapStateToProps(state: IReduxState, ownProps: any) {
const { _hasScroll = false, filmstripType, _topPanelFilmstrip, _remoteParticipants } = ownProps;
const { toolbarButtons } = state['features/toolbox'];
const { iAmRecorder } = state['features/base/config'];
const { iAmRecorder, filmstrip: { alwaysShowResizeBar } = {} } = state['features/base/config'];
const { topPanelHeight, topPanelVisible, visible, width: verticalFilmstripWidth } = state['features/filmstrip'];
const { localScreenShare } = state['features/base/participants'];
const reduceHeight = state['features/toolbox'].visible && toolbarButtons?.length;
@@ -1134,7 +1143,8 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
_topPanelVisible,
_verticalFilmstripWidth: verticalFilmstripWidth.current,
_verticalViewMaxWidth: getVerticalViewMaxWidth(state),
_videosClassName: videosClassName
_videosClassName: videosClassName,
_alwaysShowResizeBar: alwaysShowResizeBar
};
}

View File

@@ -81,13 +81,13 @@ export function shouldRemoteVideosBeVisible(state: IReduxState) {
// in the filmstrip.
const participantCount = getParticipantCountWithFake(state);
let pinnedParticipant;
const { disable1On1Mode } = state['features/base/config'];
const { disable1On1Mode, filmstrip: { alwaysShowResizeBar } = {} } = state['features/base/config'];
const { contextMenuOpened } = state['features/base/responsive-ui'];
return Boolean(
contextMenuOpened
|| participantCount > 2
|| alwaysShowResizeBar
// Always show the filmstrip when there is another participant to
// show and the local video is pinned, or the toolbar is displayed.
|| (participantCount > 1

View File

@@ -588,7 +588,7 @@ function _registerForNativeEvents(store: IStore) {
}
if (transcription) {
store.dispatch(setRequestingSubtitles(true, false, null));
store.dispatch(setRequestingSubtitles(true, false, null, true));
conference.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: true
});

View File

@@ -35,6 +35,7 @@ type ErrorType = {
*/
const RN_NO_RELOAD_DIALOG_ERRORS = [
JitsiConnectionErrors.NOT_LIVE_ERROR,
JitsiConnectionErrors.SHARD_CHANGED_ERROR,
JitsiConferenceErrors.CONFERENCE_ACCESS_DENIED,
JitsiConferenceErrors.CONFERENCE_DESTROYED,
JitsiConferenceErrors.CONNECTION_ERROR,

View File

@@ -22,6 +22,7 @@ import {
} from '../../../breakout-rooms/functions';
import { doInvitePeople } from '../../../invite/actions.native';
import { getInviteOthersControl } from '../../../share-room/functions';
import { iAmVisitor } from '../../../visitors/functions';
import { participantMatchesSearch, shouldRenderInviteButton } from '../../functions';
import MeetingParticipantItem from './MeetingParticipantItem';
@@ -37,6 +38,7 @@ const MeetingParticipantList = () => {
const keyExtractor
= useCallback((e: undefined, i: number) => i.toString(), []);
const localParticipant = useSelector(getLocalParticipant);
const _iAmVisitor = useSelector(iAmVisitor);
const onInvite = useCallback(() => {
setShareDialogVisiblity(isAddPeopleFeatureEnabled, dispatch);
dispatch(doInvitePeople());
@@ -103,7 +105,10 @@ const MeetingParticipantList = () => {
placeholder = { t('participantsPane.search') }
value = { searchString } />
<FlatList
data = { [ localParticipant?.id, ...sortedRemoteParticipants ] as Array<any> }
data = { _iAmVisitor
? [ ...sortedRemoteParticipants ]
: [ localParticipant?.id, ...sortedRemoteParticipants ] as Array<any>
}
keyExtractor = { keyExtractor }
/* eslint-disable react/jsx-no-bind */

View File

@@ -2,12 +2,12 @@ import React from 'react';
import { Text } from 'react-native';
import { useSelector } from 'react-redux';
import { getParticipantCount } from '../../../base/participants/functions';
import { getParticipantCountForDisplay } from '../../../base/participants/functions';
import styles from './styles';
const ParticipantsCounter = () => {
const participantsCount = useSelector(getParticipantCount);
const participantsCount = useSelector(getParticipantCountForDisplay);
return <Text style = { styles.participantsBadge }>{participantsCount}</Text>;
};

View File

@@ -5,7 +5,7 @@ import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconUsers } from '../../../base/icons/svg';
import { getParticipantCount } from '../../../base/participants/functions';
import { getParticipantCountForDisplay } from '../../../base/participants/functions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { navigate }
from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
@@ -83,7 +83,7 @@ class ParticipantsPaneButton extends AbstractButton<IProps> {
*/
function mapStateToProps(state: IReduxState) {
return {
_participantsCount: getParticipantCount(state)
_participantsCount: getParticipantCountForDisplay(state)
};
}

View File

@@ -0,0 +1,169 @@
/* eslint-disable react/no-multi-comp */
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowDown, IconArrowUp } from '../../../base/icons/svg';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
import { normalizeAccents } from '../../../base/util/strings.web';
import { subscribeVisitorsList } from '../../../visitors/actions';
import {
getVisitorsCount,
getVisitorsList,
isVisitorsListEnabled,
isVisitorsListSubscribed,
shouldDisplayCurrentVisitorsList
} from '../../../visitors/functions';
import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants';
import ParticipantItem from './ParticipantItem';
/**
* Props for the {@code CurrentVisitorsList} component.
*/
interface IProps {
searchString: string;
}
const useStyles = makeStyles()(theme => {
return {
container: {
marginTop: theme.spacing(3),
display: 'flex',
flexDirection: 'column',
minHeight: 0,
flexGrow: 1
},
heading: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
padding: `${theme.spacing(1)} 0`,
...withPixelLineHeight(theme.typography.bodyShortBold),
color: theme.palette.text02,
flexShrink: 0
},
arrowContainer: {
backgroundColor: theme.palette.ui03,
width: '24px',
height: '24px',
borderRadius: '6px',
marginLeft: theme.spacing(2),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none'
},
listContainer: {
flex: 1,
minHeight: '200px',
maxHeight: '100%'
}
};
});
/**
* Renders the visitors list inside the participants pane.
*
* @param {IProps} props - Component props.
* @returns {React$Element<any>} The component.
*/
export default function CurrentVisitorsList({ searchString }: IProps) {
const visitorsCount = useSelector(getVisitorsCount);
const visitors = useSelector(getVisitorsList);
const featureEnabled = useSelector(isVisitorsListEnabled);
const shouldDisplayList = useSelector(shouldDisplayCurrentVisitorsList);
const { t } = useTranslation();
const { classes } = useStyles();
const dispatch = useDispatch();
const [ collapsed, setCollapsed ] = useState(true);
const isSubscribed = useSelector(isVisitorsListSubscribed);
const toggleCollapsed = useCallback(() => {
setCollapsed(c => {
const newCollapsed = !c;
if (featureEnabled && !newCollapsed && !isSubscribed) {
dispatch(subscribeVisitorsList());
}
return newCollapsed;
});
}, [ dispatch, isSubscribed, featureEnabled ]);
useEffect(() => {
if (featureEnabled && searchString) {
setCollapsed(false);
if (!isSubscribed) {
dispatch(subscribeVisitorsList());
}
}
}, [ searchString, dispatch, isSubscribed, featureEnabled ]);
if (!shouldDisplayList) {
return null;
}
const filtered = visitors.filter(v =>
normalizeAccents(v.name).toLowerCase().includes(normalizeAccents(searchString).toLowerCase())
);
// ListItem height is 56px including padding so the item size
// for virtualization needs to match it exactly to avoid clipping.
const itemSize = 56;
const Row = ({ index, style }: { index: number; style: any; }) => {
const v = filtered[index];
return (
<div style = { style }>
<ParticipantItem
actionsTrigger = { ACTION_TRIGGER.HOVER }
audioMediaState = { MEDIA_STATE.NONE }
displayName = { v.name }
participantID = { v.id }
videoMediaState = { MEDIA_STATE.NONE } />
</div>
);
};
const styles = {
overflowX: 'hidden' as const,
overflowY: 'auto' as const,
};
return (
<div className = { classes.container }>
<div
className = { classes.heading }
onClick = { toggleCollapsed }>
<span>{ t('participantsPane.headings.visitorsList', { count: visitorsCount }) }</span>
<span className = { classes.arrowContainer }>
<Icon
size = { 14 }
src = { collapsed ? IconArrowDown : IconArrowUp } />
</span>
</div>
{!collapsed && (
<div className = { classes.listContainer }>
<AutoSizer>
{ ({ height, width }) => (
<FixedSizeList
height = { Math.max(height, 200) }
itemCount = { filtered.length }
itemSize = { itemSize }
style = { styles }
width = { width }>
{ Row }
</FixedSizeList>
)}
</AutoSizer>
</div>
)}
</div>
);
}

View File

@@ -15,6 +15,7 @@ import { normalizeAccents } from '../../../base/util/strings.web';
import { getBreakoutRooms, getCurrentRoomId, isInBreakoutRoom } from '../../../breakout-rooms/functions';
import { isButtonEnabled, showOverflowDrawer } from '../../../toolbox/functions.web';
import { muteRemote } from '../../../video-menu/actions.web';
import { iAmVisitor } from '../../../visitors/functions';
import { getSortedParticipantIds, isCurrentRoomRenamable, shouldRenderInviteButton } from '../../functions';
import { useParticipantDrawer } from '../../hooks';
import RenameButton from '../breakout-rooms/components/web/RenameButton';
@@ -174,12 +175,18 @@ function MeetingParticipants({
*/
function _mapStateToProps(state: IReduxState) {
let sortedParticipantIds: any = getSortedParticipantIds(state);
const _iAmVisitor = iAmVisitor(state);
// Filter out the virtual screenshare participants since we do not want them to be displayed as separate
// participants in the participants pane.
// Filter local participant when in visitor mode
sortedParticipantIds = sortedParticipantIds.filter((id: any) => {
const participant = getParticipantById(state, id);
if (_iAmVisitor && participant?.local) {
return false;
}
return !isScreenShareParticipant(participant);
});

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { getParticipantCount } from '../../../base/participants/functions';
import { getParticipantCountForDisplay } from '../../../base/participants/functions';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
const useStyles = makeStyles()(theme => {
@@ -26,7 +26,7 @@ const useStyles = makeStyles()(theme => {
const ParticipantsCounter = () => {
const { classes } = useStyles();
const participantsCount = useSelector(getParticipantCount);
const participantsCount = useSelector(getParticipantCountForDisplay);
return <span className = { classes.badge }>{participantsCount}</span>;
};

View File

@@ -6,6 +6,7 @@ import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import participantsPaneTheme from '../../../base/components/themes/participantsPaneTheme.json';
import { openDialog } from '../../../base/dialog/actions';
import { isMobileBrowser } from '../../../base/environment/utils';
import { IconCloseLarge, IconDotsHorizontal } from '../../../base/icons/svg';
import { isLocalParticipantModerator } from '../../../base/participants/functions';
import Button from '../../../base/ui/components/web/Button';
@@ -14,6 +15,7 @@ import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import { findAncestorByClass } from '../../../base/ui/functions.web';
import { isAddBreakoutRoomButtonVisible } from '../../../breakout-rooms/functions';
import MuteEveryoneDialog from '../../../video-menu/components/web/MuteEveryoneDialog';
import { shouldDisplayCurrentVisitorsList } from '../../../visitors/functions';
import { close } from '../../actions.web';
import {
getParticipantsPaneOpen,
@@ -23,11 +25,11 @@ import {
import { AddBreakoutRoomButton } from '../breakout-rooms/components/web/AddBreakoutRoomButton';
import { RoomList } from '../breakout-rooms/components/web/RoomList';
import CurrentVisitorsList from './CurrentVisitorsList';
import { FooterContextMenu } from './FooterContextMenu';
import LobbyParticipants from './LobbyParticipants';
import MeetingParticipants from './MeetingParticipants';
import VisitorsList from './VisitorsList';
import { isMobileBrowser } from '../../../base/environment/utils';
/**
* Interface representing the properties used for styles.
@@ -73,6 +75,8 @@ const useStyles = makeStyles<IStylesProps>()((theme, { isChatOpen }) => {
overflowY: 'auto',
position: 'relative',
padding: `0 ${participantsPaneTheme.panePadding}px`,
display: 'flex',
flexDirection: 'column',
'&::-webkit-scrollbar': {
display: 'none'
@@ -129,6 +133,7 @@ const ParticipantsPane = () => {
const paneOpen = useSelector(getParticipantsPaneOpen);
const isBreakoutRoomsSupported = useSelector((state: IReduxState) => state['features/base/conference'])
.conference?.getBreakoutRooms()?.isSupported();
const showCurrentVisitorsList = useSelector(shouldDisplayCurrentVisitorsList);
const showAddRoomButton = useSelector(isAddBreakoutRoomButtonVisible);
const showFooter = useSelector(isLocalParticipantModerator);
const showMuteAllButton = useSelector(isMuteAllVisible);
@@ -193,6 +198,7 @@ const ParticipantsPane = () => {
setSearchString = { setSearchString } />
{isBreakoutRoomsSupported && <RoomList searchString = { searchString } />}
{showAddRoomButton && <AddBreakoutRoomButton />}
{showCurrentVisitorsList && <CurrentVisitorsList searchString = { searchString } />}
</div>
{showFooter && (
<div className = { classes.footer }>

Some files were not shown because too many files have changed in this diff Show More