From 7660171aff27c5ab59cb68eaf67496393b33e8af Mon Sep 17 00:00:00 2001 From: Jan Kessler Date: Tue, 16 May 2023 07:06:55 +0200 Subject: [PATCH 0001/1039] set bbb-rap-resque-worker niceness to 19 --- record-and-playback/core/systemd/bbb-rap-resque-worker.service | 1 + 1 file changed, 1 insertion(+) diff --git a/record-and-playback/core/systemd/bbb-rap-resque-worker.service b/record-and-playback/core/systemd/bbb-rap-resque-worker.service index a3debec52c..9192426084 100644 --- a/record-and-playback/core/systemd/bbb-rap-resque-worker.service +++ b/record-and-playback/core/systemd/bbb-rap-resque-worker.service @@ -14,6 +14,7 @@ Environment=COUNT=1 User=bigbluebutton Restart=always RestartSec=3 +Nice=19 [Install] WantedBy=multi-user.target bigbluebutton.target From 13ddb913984227739dc0fe7bbfdec283ec39fb2b Mon Sep 17 00:00:00 2001 From: Jan Kessler Date: Tue, 16 May 2023 07:09:26 +0200 Subject: [PATCH 0002/1039] set bbb-rap-caption-inbox niceness to 19 --- record-and-playback/core/systemd/bbb-rap-caption-inbox.service | 1 + 1 file changed, 1 insertion(+) diff --git a/record-and-playback/core/systemd/bbb-rap-caption-inbox.service b/record-and-playback/core/systemd/bbb-rap-caption-inbox.service index 9f0a0d9a47..d4c30819e4 100644 --- a/record-and-playback/core/systemd/bbb-rap-caption-inbox.service +++ b/record-and-playback/core/systemd/bbb-rap-caption-inbox.service @@ -9,6 +9,7 @@ WorkingDirectory=/usr/local/bigbluebutton/core User=bigbluebutton Slice=bbb_record_core.slice Restart=on-failure +Nice=19 [Install] WantedBy=multi-user.target bigbluebutton.target From 0ff5011a0e5c057c38f82024d9a5d8b8b88d2c6c Mon Sep 17 00:00:00 2001 From: wilkis Date: Mon, 10 Jul 2023 12:55:49 +0200 Subject: [PATCH 0003/1039] Update customize.md --- docs/docs/administration/customize.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/administration/customize.md b/docs/docs/administration/customize.md index 7b5141b8dc..ce97d05472 100644 --- a/docs/docs/administration/customize.md +++ b/docs/docs/administration/customize.md @@ -802,7 +802,7 @@ To create the dialplan, use the XML below and save it to `/opt/freeswitch/conf/d - + @@ -825,7 +825,7 @@ To create the dialplan, use the XML below and save it to `/opt/freeswitch/conf/d - + From 6d302980248de5439f24c9e91a1541d6c6d4faf7 Mon Sep 17 00:00:00 2001 From: wilkis Date: Mon, 10 Jul 2023 22:02:54 +0200 Subject: [PATCH 0004/1039] Update install.md --- docs/docs/administration/install.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/docs/administration/install.md b/docs/docs/administration/install.md index d15674a496..e32160411e 100644 --- a/docs/docs/administration/install.md +++ b/docs/docs/administration/install.md @@ -389,14 +389,10 @@ Note: These examples are _not_ maintained or developed by the official BigBlueBu These first two install BigBlueButton on your server in a consistent fashion. You can specify variables, such as whether to install Greenlight too, what ports to use for TURN, and others. Functionally quite similar to bbb-install.sh but highly automated. -- [General Ansible role for BigBlueButton](https://github.com/n0emis/ansible-role-bigbluebutton) -- [Alternative Ansible role for BigBlueButton](https://github.com/juanluisbaptiste/ansible-bigbluebutton) +- [General Ansible role for BigBlueButton](https://github.com/ebbba-org/ansible-role-bigbluebutton) Large scale deployments must include several other components in addition to the core BigBlueButton packages. These include Scalelite, Greenlight, a database, backups, nginx configurations, and more. -- [Full out-of-the-box setup with wiki, chat, backups](https://github.com/stadtulm/a13-ansible) -- [Full out-of-the-box setup with frontend on one machine](https://github.com/srcf/timeout) -- [Full setup for a university](https://github.com/unistra/bigbluebutton/) - [Full HA setup with PeerTube, Conferences Streaming, EFK, Prometheus, backups](https://github.com/Worteks/bbb-ansible) ## Customizations From ebdec728a806fdb23a0cfd0b8f43e9dacf5e7610 Mon Sep 17 00:00:00 2001 From: wilkis Date: Mon, 10 Jul 2023 22:15:10 +0200 Subject: [PATCH 0005/1039] Update install.md --- docs/docs/administration/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/administration/install.md b/docs/docs/administration/install.md index e32160411e..81bb9c7666 100644 --- a/docs/docs/administration/install.md +++ b/docs/docs/administration/install.md @@ -387,7 +387,7 @@ Choose this method if you are already comfortable with a lot of the technical kn Note: These examples are _not_ maintained or developed by the official BigBlueButton developers. These are entirely community-sourced, use at your own discretion. -These first two install BigBlueButton on your server in a consistent fashion. You can specify variables, such as whether to install Greenlight too, what ports to use for TURN, and others. Functionally quite similar to bbb-install.sh but highly automated. +The first install BigBlueButton on your server in a consistent fashion. You can specify variables, such as what ports to use for TURN, and others. Functionally quite similar to bbb-install.sh but highly automated. - [General Ansible role for BigBlueButton](https://github.com/ebbba-org/ansible-role-bigbluebutton) From 27ae5985d738c0a1f90a70d41af52224c2a0e695 Mon Sep 17 00:00:00 2001 From: prlanzarin <4529051+prlanzarin@users.noreply.github.com> Date: Wed, 16 Aug 2023 22:20:01 -0300 Subject: [PATCH 0006/1039] refactor: remove unused DeskShareStartRTMP/DeskShareStopRTMP events DeskShareStartRTMP/DeskShareStopRTMP events are still recorded to Redis by akka-apps, triggered by ScreenshareRtmpBroadcastStarted/Stopped events. The latter two are still used internally (the RTMP being legacy naming from when the HTML5 client interoperated with the Flash client), but the recording events are *not* used anymore. They've long been replaced by StartWebRTCDesktopShareEvent/StartWebRTCDesktopShareEvent. Remove DeskShareStartRTMP/DeskShareStopRTMP event generation and associated code, alongside with other remainders of the old deskshare implementation in InMessages.scala. --- .../bigbluebutton/core/api/InMessages.scala | 7 ---- .../events/AbstractDeskshareRecordEvent.scala | 24 ------------- .../DeskshareStartRtmpRecordEvent.scala | 34 ------------------- .../events/DeskshareStopRtmpRecordEvent.scala | 34 ------------------- .../endpoint/redis/RedisRecorderActor.scala | 32 ----------------- 5 files changed, 131 deletions(-) delete mode 100755 akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/AbstractDeskshareRecordEvent.scala delete mode 100755 akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/DeskshareStartRtmpRecordEvent.scala delete mode 100755 akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/DeskshareStopRtmpRecordEvent.scala diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/api/InMessages.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/api/InMessages.scala index 1dbdb9f650..0b6e7c7dbc 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/api/InMessages.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/api/InMessages.scala @@ -119,10 +119,3 @@ case class CapturePresentationReqInternalMsg(userId: String, parentMeetingId: St * @param filename */ case class CaptureSharedNotesReqInternalMsg(breakoutId: String, filename: String) extends InMessage - -// DeskShare -case class DeskShareStartedRequest(conferenceName: String, callerId: String, callerIdName: String) extends InMessage -case class DeskShareStoppedRequest(conferenceName: String, callerId: String, callerIdName: String) extends InMessage -case class DeskShareRTMPBroadcastStartedRequest(conferenceName: String, streamname: String, videoWidth: Int, videoHeight: Int, timestamp: String) extends InMessage -case class DeskShareRTMPBroadcastStoppedRequest(conferenceName: String, streamname: String, videoWidth: Int, videoHeight: Int, timestamp: String) extends InMessage -case class DeskShareGetDeskShareInfoRequest(conferenceName: String, requesterID: String, replyTo: String) extends InMessage diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/AbstractDeskshareRecordEvent.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/AbstractDeskshareRecordEvent.scala deleted file mode 100755 index d47f6cd5cf..0000000000 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/AbstractDeskshareRecordEvent.scala +++ /dev/null @@ -1,24 +0,0 @@ -/** - * BigBlueButton open source conferencing system - http://www.bigbluebutton.org/ - * - * Copyright (c) 2017 BigBlueButton Inc. and by respective authors (see below). - * - * This program is free software; you can redistribute it and/or modify it under the - * terms of the GNU Lesser General Public License as published by the Free Software - * Foundation; either version 3.0 of the License, or (at your option) any later - * version. - * - * BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A - * PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License along - * with BigBlueButton; if not, see . - * - */ - -package org.bigbluebutton.core.record.events - -trait AbstractDeskshareRecordEvent extends RecordEvent { - setModule("DESKSHARE") -} \ No newline at end of file diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/DeskshareStartRtmpRecordEvent.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/DeskshareStartRtmpRecordEvent.scala deleted file mode 100755 index d6ffc888d9..0000000000 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/DeskshareStartRtmpRecordEvent.scala +++ /dev/null @@ -1,34 +0,0 @@ -/** - * BigBlueButton open source conferencing system - http://www.bigbluebutton.org/ - * - * Copyright (c) 2017 BigBlueButton Inc. and by respective authors (see below). - * - * This program is free software; you can redistribute it and/or modify it under the - * terms of the GNU Lesser General Public License as published by the Free Software - * Foundation; either version 3.0 of the License, or (at your option) any later - * version. - * - * BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A - * PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License along - * with BigBlueButton; if not, see . - * - */ - -package org.bigbluebutton.core.record.events - -class DeskshareStartRtmpRecordEvent extends AbstractDeskshareRecordEvent { - import DeskshareStartRtmpRecordEvent._ - - setEvent("DeskShareStartRTMP") - - def setStreamPath(streamPath: String) { - eventMap.put(STREAM_PATH, streamPath) - } -} - -object DeskshareStartRtmpRecordEvent { - protected final val STREAM_PATH = "startIstreamPathndex" -} \ No newline at end of file diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/DeskshareStopRtmpRecordEvent.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/DeskshareStopRtmpRecordEvent.scala deleted file mode 100755 index 0fd97963b9..0000000000 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/DeskshareStopRtmpRecordEvent.scala +++ /dev/null @@ -1,34 +0,0 @@ -/** - * BigBlueButton open source conferencing system - http://www.bigbluebutton.org/ - * - * Copyright (c) 2017 BigBlueButton Inc. and by respective authors (see below). - * - * This program is free software; you can redistribute it and/or modify it under the - * terms of the GNU Lesser General Public License as published by the Free Software - * Foundation; either version 3.0 of the License, or (at your option) any later - * version. - * - * BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A - * PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License along - * with BigBlueButton; if not, see . - * - */ - -package org.bigbluebutton.core.record.events - -class DeskshareStopRtmpRecordEvent extends AbstractDeskshareRecordEvent { - import DeskshareStopRtmpRecordEvent._ - - setEvent("DeskShareStopRTMP") - - def setStreamPath(streamPath: String) { - eventMap.put(STREAM_PATH, streamPath) - } -} - -object DeskshareStopRtmpRecordEvent { - protected final val STREAM_PATH = "startIstreamPathndex" -} \ No newline at end of file diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/RedisRecorderActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/RedisRecorderActor.scala index ae91ed9b5f..2b020e8cc8 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/RedisRecorderActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/RedisRecorderActor.scala @@ -106,11 +106,6 @@ class RedisRecorderActor( // Pads case m: PadCreatedRespMsg => handlePadCreatedRespMsg(m) - // Screenshare - case m: ScreenshareRtmpBroadcastStartedEvtMsg => handleScreenshareRtmpBroadcastStartedEvtMsg(m) - case m: ScreenshareRtmpBroadcastStoppedEvtMsg => handleScreenshareRtmpBroadcastStoppedEvtMsg(m) - //case m: DeskShareNotifyViewersRTMP => handleDeskShareNotifyViewersRTMP(m) - // AudioCaptions case m: TranscriptUpdatedEvtMsg => handleTranscriptUpdatedEvtMsg(m) @@ -494,33 +489,6 @@ class RedisRecorderActor( record(msg.header.meetingId, ev.toMap.asJava) } - private def handleScreenshareRtmpBroadcastStartedEvtMsg(msg: ScreenshareRtmpBroadcastStartedEvtMsg) { - val ev = new DeskshareStartRtmpRecordEvent() - ev.setMeetingId(msg.header.meetingId) - ev.setStreamPath(msg.body.stream) - - record(msg.header.meetingId, ev.toMap.asJava) - } - - private def handleScreenshareRtmpBroadcastStoppedEvtMsg(msg: ScreenshareRtmpBroadcastStoppedEvtMsg) { - val ev = new DeskshareStopRtmpRecordEvent() - ev.setMeetingId(msg.header.meetingId) - ev.setStreamPath(msg.body.stream) - - record(msg.header.meetingId, ev.toMap.asJava) - } - - /* - private def handleDeskShareNotifyViewersRTMP(msg: DeskShareNotifyViewersRTMP) { - val ev = new DeskShareNotifyViewersRTMPRecordEvent() - ev.setMeetingId(msg.header.meetingId) - ev.setStreamPath(msg.streamPath) - ev.setBroadcasting(msg.broadcasting) - - record(msg.header.meetingId, JavaConverters.mapAsScalaMap(ev.toMap).toMap) - } - */ - private def handleTranscriptUpdatedEvtMsg(msg: TranscriptUpdatedEvtMsg) { val ev = new TranscriptUpdatedRecordEvent() ev.setMeetingId(msg.header.meetingId) From 774693121ae1df589f6d6026435853a847d98009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hubert=20Figui=C3=A8re?= Date: Tue, 12 Sep 2023 09:41:55 -0400 Subject: [PATCH 0007/1039] lo-conversion: add a script to use CODE for remote conversion --- bbb-libreoffice/assets/convert-cool.sh | 51 +++++++++++++++++++ bbb-libreoffice/install-local.sh | 8 ++- bbb-libreoffice/install-remote.sh | 8 ++- .../bbb-libreoffice-docker/after-install.sh | 1 + .../bbb-libreoffice-docker/build.sh | 2 + 5 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 bbb-libreoffice/assets/convert-cool.sh diff --git a/bbb-libreoffice/assets/convert-cool.sh b/bbb-libreoffice/assets/convert-cool.sh new file mode 100644 index 0000000000..918fa79b85 --- /dev/null +++ b/bbb-libreoffice/assets/convert-cool.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -e +set -u +PATH="/bin/:/usr/bin/" + +# This is a sample script - adjust it per your need, to use Collabora online. +# 1 - setup a server with Collabora Online (CODE) +# You can run it with +# docker run -t -d -p 127.0.0.1:9980:9980 -e "domain=" \ +# -e "username=admin" -e "password=S3cRet" --restart always collabora/code +# See https://sdk.collaboraonline.com/docs/installation/CODE_Docker_image.html +# Or you can use an existing setup you have. +# 2 - replace the HOST information below with your server host + +HOST=127.0.0.1 + +# Set this to "-k" to allow it to work in a test environment, ie with a self signed +# certificate +UNSECURE= + +# This script receives three params +# Param 1: Input office file path (e.g. "/tmp/test.odt") +# Param 2: Output pdf file path (e.g. "/tmp/test.pdf") +# Param 3: Destination Format (pdf default) +# Param 4: Timeout (secs) (optional) + +if (( $# == 0 )); then + echo "Missing parameter 1 (Input office file path)"; + exit 1 +elif (( $# == 1 )); then + echo "Missing parameter 2 (Output pdf file path)"; + exit 1 +fi; + + +source="$1" +dest="$2" + +# If output format is missing, define PDF +convertTo="${3:-pdf}" + +# If timeout is missing, define 60 +timeoutSecs="${4:-60}" +# Truncate timeout to max 3 digits (as expected by sudoers) +timeoutSecs="${timeoutSecs:0:3}" + +# The timeout is important. + +timeout $(printf %03d $timeoutSecs)s curl $UNSECURE -F "data=@${source}" https://$HOST:9980/cool/convert-to/$convertTo > "${dest}" + +exit 0 diff --git a/bbb-libreoffice/install-local.sh b/bbb-libreoffice/install-local.sh index 2e3448fb91..675128adc9 100755 --- a/bbb-libreoffice/install-local.sh +++ b/bbb-libreoffice/install-local.sh @@ -34,11 +34,9 @@ fi FOLDER_CHECK=`[ -d /usr/share/bbb-libreoffice-conversion/ ] && echo 1 || echo 0` if [ "$FOLDER_CHECK" = "0" ]; then echo "Install folder doesn't exists, installing" - mkdir -m 755 /usr/share/bbb-libreoffice-conversion/ - cp assets/convert-local.sh /usr/share/bbb-libreoffice-conversion/convert.sh - chmod 755 /usr/share/bbb-libreoffice-conversion/convert.sh - cp assets/etherpad-export.sh /usr/share/bbb-libreoffice-conversion/etherpad-export.sh - chmod 755 /usr/share/bbb-libreoffice-conversion/etherpad-export.sh + install -Dm755 assets/convert-local.sh /usr/share/bbb-libreoffice-conversion/convert.sh + install -Dm755 assets/convert-cool.sh /usr/share/bbb-libreoffice-conversion/convert-cool.sh + install -Dm755 assets/etherpad-export.sh /usr/share/bbb-libreoffice-conversion/etherpad-export.sh chown -R root /usr/share/bbb-libreoffice-conversion/ else echo "Install folder already exists" diff --git a/bbb-libreoffice/install-remote.sh b/bbb-libreoffice/install-remote.sh index e5a75eb05c..b19712b4e2 100755 --- a/bbb-libreoffice/install-remote.sh +++ b/bbb-libreoffice/install-remote.sh @@ -9,11 +9,9 @@ cd "$(dirname "$0")" FOLDER_CHECK=`[ -d /usr/share/bbb-libreoffice-conversion/ ] && echo 1 || echo 0` if [ "$FOLDER_CHECK" = "0" ]; then echo "Install folder doesn't exists, installing" - mkdir -m 755 /usr/share/bbb-libreoffice-conversion/ - cp assets/convert-remote.sh /usr/share/bbb-libreoffice-conversion/convert.sh - chmod 755 /usr/share/bbb-libreoffice-conversion/convert.sh - cp assets/etherpad-export.sh /usr/share/bbb-libreoffice-conversion/etherpad-export.sh - chmod 755 /usr/share/bbb-libreoffice-conversion/etherpad-export.sh + install -Dm755 assets/convert-remote.sh /usr/share/bbb-libreoffice-conversion/convert.sh + install -Dm755 assets/convert-cool.sh /usr/share/bbb-libreoffice-conversion/convert-cool.sh + install -Dm755 assets/etherpad-export.sh /usr/share/bbb-libreoffice-conversion/etherpad-export.sh chown -R root /usr/share/bbb-libreoffice-conversion/ else echo "Install folder already exists" diff --git a/build/packages-template/bbb-libreoffice-docker/after-install.sh b/build/packages-template/bbb-libreoffice-docker/after-install.sh index 43d8a46254..4f53a69508 100755 --- a/build/packages-template/bbb-libreoffice-docker/after-install.sh +++ b/build/packages-template/bbb-libreoffice-docker/after-install.sh @@ -16,6 +16,7 @@ fi #fi +chmod +x /usr/share/bbb-libreoffice-conversion/convert-cool.sh chmod +x /usr/share/bbb-libreoffice-conversion/convert-local.sh chmod +x /usr/share/bbb-libreoffice-conversion/convert-remote.sh chmod +x /usr/share/bbb-libreoffice-conversion/etherpad-export.sh diff --git a/build/packages-template/bbb-libreoffice-docker/build.sh b/build/packages-template/bbb-libreoffice-docker/build.sh index 3a26c8dfa0..c73109bbc5 100755 --- a/build/packages-template/bbb-libreoffice-docker/build.sh +++ b/build/packages-template/bbb-libreoffice-docker/build.sh @@ -21,9 +21,11 @@ if [ $DISTRO != "amzn2" ]; then fi cp assets/etherpad-export.sh staging/usr/share/bbb-libreoffice-conversion/etherpad-export.sh +cp assets/convert-local.sh staging/usr/share/bbb-libreoffice-conversion/convert-cool.sh cp assets/convert-local.sh staging/usr/share/bbb-libreoffice-conversion/convert-local.sh cp assets/convert-remote.sh staging/usr/share/bbb-libreoffice-conversion/convert-remote.sh +chmod +x staging/usr/share/bbb-libreoffice-conversion/convert-cool.sh chmod +x staging/usr/share/bbb-libreoffice-conversion/convert-local.sh chmod +x staging/usr/share/bbb-libreoffice-conversion/convert-remote.sh chmod +x staging/usr/share/bbb-libreoffice-conversion/etherpad-export.sh From aa27e8be68bccfbe5c9062fd4c4cd5ef37547466 Mon Sep 17 00:00:00 2001 From: Tainan Felipe Date: Thu, 21 Sep 2023 10:48:00 -0300 Subject: [PATCH 0008/1039] Refactor: migrate audio captions to TS + Graphql --- bigbluebutton-html5/.eslintrc.js | 4 +- .../audio-captions/button/component.tsx | 294 ++++++++++++++++++ .../audio-captions/button/queries.ts | 23 ++ .../audio-captions/button/styles.ts | 62 ++++ .../audio-captions/captions/component.tsx | 153 +++++++++ .../audio-captions/live/component.tsx | 108 +++++++ .../audio-captions/live/queries.ts | 34 ++ .../audio-captions/live/styles.ts | 139 +++++++++ .../audio-graphql/audio-captions/service.ts | 55 ++++ .../audio-captions/speech/component.tsx | 170 ++++++++++ .../audio-captions/speech/service.ts | 121 +++++++ .../audio/captions/button/container.jsx | 5 +- .../audio/captions/live/container.jsx | 5 +- .../audio/captions/select/container.jsx | 5 +- .../audio/captions/speech/container.jsx | 5 +- .../queries/currentUserSubscription.ts | 6 +- .../local-states/useAudioCaptionEnable.ts | 7 + .../ui/services/audio-manager/index.js | 2 +- 18 files changed, 1190 insertions(+), 8 deletions(-) create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/button/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/button/queries.ts create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/button/styles.ts create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/captions/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/live/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/live/queries.ts create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/live/styles.ts create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/service.ts create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/speech/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/speech/service.ts create mode 100644 bigbluebutton-html5/imports/ui/core/local-states/useAudioCaptionEnable.ts diff --git a/bigbluebutton-html5/.eslintrc.js b/bigbluebutton-html5/.eslintrc.js index c0fc280715..ceb6090379 100644 --- a/bigbluebutton-html5/.eslintrc.js +++ b/bigbluebutton-html5/.eslintrc.js @@ -28,10 +28,12 @@ module.exports = { overrides: [ { files: ['*.ts', '*.tsx'], - extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'airbnb'], + extends: ['eslint:recommended', 'airbnb', 'plugin:@typescript-eslint/recommended'], parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint'], rules: { + '@typescript-eslint/ban-ts-comment': 'off', + camelcase: 'off', 'no-use-before-define': 'off', 'import/no-absolute-path': 0, 'import/no-unresolved': 0, diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/button/component.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/button/component.tsx new file mode 100644 index 0000000000..f4c37699e8 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/button/component.tsx @@ -0,0 +1,294 @@ +import React, { useEffect, useRef } from 'react'; +import { useSubscription } from '@apollo/client'; +import { layoutSelect } from '/imports/ui/components/layout/context'; +import { Layout } from '/imports/ui/components/layout/layoutTypes'; +import { useCurrentUser } from '/imports/ui/core/hooks/useCurrentUser'; +import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji'; +import BBBMenu from '/imports/ui/components/common/menu/component'; +import Styled from './styles'; +import { getSpeechVoices, setAudioCaptions, setSpeechLocale } from '../service'; +import { GET_AUDIO_CAPTIONS_COUNT, GetAudioCaptionsCountResponse } from './queries'; +import { defineMessages, useIntl } from 'react-intl'; +import { MenuSeparatorItemType, MenuOptionItemType } from '/imports/ui/components/common/menu/menuTypes'; +import useAudioCaptionEnable from '/imports/ui/core/local-states/useAudioCaptionEnable'; +import logger from '/imports/startup/client/logger'; + +const intlMessages = defineMessages({ + start: { + id: 'app.audio.captions.button.start', + description: 'Start audio captions', + }, + stop: { + id: 'app.audio.captions.button.stop', + description: 'Stop audio captions', + }, + transcriptionSettings: { + id: 'app.audio.captions.button.transcriptionSettings', + description: 'Audio captions settings modal', + }, + transcription: { + id: 'app.audio.captions.button.transcription', + description: 'Audio speech transcription label', + }, + transcriptionOn: { + id: 'app.switch.onLabel', + }, + transcriptionOff: { + id: 'app.switch.offLabel', + }, + language: { + id: 'app.audio.captions.button.language', + description: 'Audio speech recognition language label', + }, + 'de-DE': { + id: 'app.audio.captions.select.de-DE', + description: 'Audio speech recognition german language', + }, + 'en-US': { + id: 'app.audio.captions.select.en-US', + description: 'Audio speech recognition english language', + }, + 'es-ES': { + id: 'app.audio.captions.select.es-ES', + description: 'Audio speech recognition spanish language', + }, + 'fr-FR': { + id: 'app.audio.captions.select.fr-FR', + description: 'Audio speech recognition french language', + }, + 'hi-ID': { + id: 'app.audio.captions.select.hi-ID', + description: 'Audio speech recognition indian language', + }, + 'it-IT': { + id: 'app.audio.captions.select.it-IT', + description: 'Audio speech recognition italian language', + }, + 'ja-JP': { + id: 'app.audio.captions.select.ja-JP', + description: 'Audio speech recognition japanese language', + }, + 'pt-BR': { + id: 'app.audio.captions.select.pt-BR', + description: 'Audio speech recognition portuguese language', + }, + 'ru-RU': { + id: 'app.audio.captions.select.ru-RU', + description: 'Audio speech recognition russian language', + }, + 'zh-CN': { + id: 'app.audio.captions.select.zh-CN', + description: 'Audio speech recognition chinese language', + }, +}); + +interface AudioCaptionsButtonProps { + isRTL: boolean; + availableVoices: string[]; + currentSpeechLocale: string; + isSupported: boolean; + isVoiceUser: boolean; +} + +const DISABLED = ''; + +const AudioCaptionsButton: React.FC = ({ + isRTL, + currentSpeechLocale, + availableVoices, + isSupported, + isVoiceUser, +}) => { + const intl = useIntl(); + const [active] = useAudioCaptionEnable(); + + const isTranscriptionDisabled = () => currentSpeechLocale === DISABLED; + const fallbackLocale = availableVoices.includes(navigator.language) + ? navigator.language + : 'en-US'; // Assuming 'en-US' is the default fallback locale + + const getSelectedLocaleValue = isTranscriptionDisabled() + ? fallbackLocale + : currentSpeechLocale; + + const selectedLocale = useRef(getSelectedLocaleValue); + + useEffect(() => { + if (!isTranscriptionDisabled()) selectedLocale.current = getSelectedLocaleValue; + }, [currentSpeechLocale]); + + const shouldRenderChevron = isSupported && isVoiceUser; + + const toggleTranscription = () => { + setSpeechLocale(isTranscriptionDisabled() ? selectedLocale.current : DISABLED); + }; + + const getAvailableLocales = () => { + let indexToInsertSeparator = -1; + const availableVoicesObjectToMenu: (MenuOptionItemType | MenuSeparatorItemType)[] = availableVoices + .map((availableVoice: string, index: number) => { + if (availableVoice === availableVoices[0]) { + indexToInsertSeparator = index; + } + return ( + { + icon: '', + label: intl.formatMessage(intlMessages[availableVoice as keyof typeof intlMessages]), + key: availableVoice, + iconRight: selectedLocale.current === availableVoice ? 'check' : null, + customStyles: (selectedLocale.current === availableVoice) && Styled.SelectedLabel, + disabled: isTranscriptionDisabled(), + onClick: () => { + selectedLocale.current = availableVoice; + setSpeechLocale(selectedLocale.current); + }, + } + ); + }); + if (indexToInsertSeparator >= 0) { + availableVoicesObjectToMenu.splice(indexToInsertSeparator, 0, { + key: 'separator-01', + isSeparator: true, + }); + } + return [ + ...availableVoicesObjectToMenu, + ]; + }; + + const getAvailableLocalesList = () => ( + [{ + key: 'availableLocalesList', + label: intl.formatMessage(intlMessages.language), + customStyles: Styled.TitleLabel, + disabled: true, + }, + ...getAvailableLocales(), + { + key: 'divider', + label: intl.formatMessage(intlMessages.transcription), + customStyles: Styled.TitleLabel, + disabled: true, + }, + { + key: 'separator-02', + isSeparator: true, + }, + { + key: 'transcriptionStatus', + label: intl.formatMessage( + isTranscriptionDisabled() + ? intlMessages.transcriptionOn + : intlMessages.transcriptionOff, + ), + customStyles: isTranscriptionDisabled() + ? Styled.EnableTrascription : Styled.DisableTrascription, + disabled: false, + onClick: toggleTranscription, + }] + ); + const onToggleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setAudioCaptions(!active); + }; + + const startStopCaptionsButton = ( + + ); + + return ( + shouldRenderChevron + ? ( + + + { startStopCaptionsButton } + + + )} + actions={getAvailableLocalesList()} + opts={{ + id: 'default-dropdown-menu', + keepMounted: true, + transitionDuration: 0, + elevation: 3, + getcontentanchorel: null, + fullwidth: 'true', + anchorOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' }, + transformOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' }, + }} + /> + + ) : startStopCaptionsButton + ); +}; + +const AudioCaptionsButtonContainer: React.FC = () => { + const isRTL = layoutSelect((i: Layout) => i.isRTL); + const currentUser = useCurrentUser( + (user) => ({ + speechLocale: user.speechLocale, + voice: user.voice, + }), + ); + + const { + data: audioCaptionsCountData, + loading: audioCaptionsCountLoading, + error: audioCaptionsCountError, + } = useSubscription(GET_AUDIO_CAPTIONS_COUNT); + + if (!currentUser || audioCaptionsCountLoading) return null; + + if (audioCaptionsCountError) { + logger.error(audioCaptionsCountError); + return ( +
+ { + JSON.stringify(audioCaptionsCountError) + } +
+ ); + } + + if (audioCaptionsCountData) { + const hasAudioCaptions = audioCaptionsCountData + .audio_caption_aggregate + .aggregate.count > 0; + + if (!hasAudioCaptions) return null; + } + + const availableVoices = getSpeechVoices(); + const currentSpeechLocale = currentUser.speechLocale || ''; + const isSupported = availableVoices.length > 0; + const isVoiceUser = !!currentUser.voice; + + return ( + + ); +}; + +export default AudioCaptionsButtonContainer; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/button/queries.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/button/queries.ts new file mode 100644 index 0000000000..5221a454d3 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/button/queries.ts @@ -0,0 +1,23 @@ +import { gql } from '@apollo/client'; + +export interface GetAudioCaptionsCountResponse { + audio_caption_aggregate: { + aggregate: { + count: number; + } + }; +} + +export const GET_AUDIO_CAPTIONS_COUNT = gql` + subscription GetAudioCaptionsCount { + audio_caption_aggregate { + aggregate { + count(columns: transcriptId) + } + } + } +`; + +export default { + GET_AUDIO_CAPTIONS_COUNT, +}; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/button/styles.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/button/styles.ts new file mode 100644 index 0000000000..92f7c04797 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/button/styles.ts @@ -0,0 +1,62 @@ +import styled from 'styled-components'; +import Button from '/imports/ui/components/common/button/component'; +import Toggle from '/imports/ui/components/common/switch/component'; +import { + colorWhite, + colorPrimary, + colorOffWhite, + colorDangerDark, + colorSuccess, +} from '/imports/ui/stylesheets/styled-components/palette'; + +// @ts-ignore - as button comes from JS, we can't provide its props +const ClosedCaptionToggleButton = styled(Button)` + ${({ ghost }) => ghost && ` + span { + box-shadow: none; + background-color: transparent !important; + border-color: ${colorWhite} !important; + } + i { + margin-top: .4rem; + } + `} +`; + +const SpanButtonWrapper = styled.span` + position: relative; +`; + +const TranscriptionToggle = styled(Toggle)` + display: flex; + justify-content: flex-start; + padding-left: 1em; +`; + +const TitleLabel = { + fontWeight: 'bold', + opacity: 1, +}; + +const EnableTrascription = { + color: colorSuccess, +}; + +const DisableTrascription = { + color: colorDangerDark, +}; + +const SelectedLabel = { + color: colorPrimary, + backgroundColor: colorOffWhite, +}; + +export default { + ClosedCaptionToggleButton, + SpanButtonWrapper, + TranscriptionToggle, + TitleLabel, + EnableTrascription, + DisableTrascription, + SelectedLabel, +}; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/captions/component.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/captions/component.tsx new file mode 100644 index 0000000000..f0ff7e19d0 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/captions/component.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { + getSpeechVoices, + isAudioTranscriptionEnabled, + setSpeechLocale, + useFixedLocale, +} from '../service'; +import { useCurrentUser } from '/imports/ui/core/hooks/useCurrentUser'; + +const intlMessages = defineMessages({ + title: { + id: 'app.audio.captions.speech.title', + description: 'Audio speech recognition title', + }, + disabled: { + id: 'app.audio.captions.speech.disabled', + description: 'Audio speech recognition disabled', + }, + unsupported: { + id: 'app.audio.captions.speech.unsupported', + description: 'Audio speech recognition unsupported', + }, + 'de-DE': { + id: 'app.audio.captions.select.de-DE', + description: 'Audio speech recognition german language', + }, + 'en-US': { + id: 'app.audio.captions.select.en-US', + description: 'Audio speech recognition english language', + }, + 'es-ES': { + id: 'app.audio.captions.select.es-ES', + description: 'Audio speech recognition spanish language', + }, + 'fr-FR': { + id: 'app.audio.captions.select.fr-FR', + description: 'Audio speech recognition french language', + }, + 'hi-ID': { + id: 'app.audio.captions.select.hi-ID', + description: 'Audio speech recognition indian language', + }, + 'it-IT': { + id: 'app.audio.captions.select.it-IT', + description: 'Audio speech recognition italian language', + }, + 'ja-JP': { + id: 'app.audio.captions.select.ja-JP', + description: 'Audio speech recognition japanese language', + }, + 'pt-BR': { + id: 'app.audio.captions.select.pt-BR', + description: 'Audio speech recognition portuguese language', + }, + 'ru-RU': { + id: 'app.audio.captions.select.ru-RU', + description: 'Audio speech recognition russian language', + }, + 'zh-CN': { + id: 'app.audio.captions.select.zh-CN', + description: 'Audio speech recognition chinese language', + }, +}); + +interface AudioCaptionsSelectProps { + isTranscriptionEnabled: boolean; + speechLocale: string; + speechVoices: string[]; +} + +const AudioCaptionsSelect: React.FC = ({ + isTranscriptionEnabled, + speechLocale, + speechVoices, +}) => { + const useLocaleHook = useFixedLocale(); + const intl = useIntl(); + if (!isTranscriptionEnabled || useLocaleHook) return null; + + if (speechVoices.length === 0) { + return ( +
+ {`*${intl.formatMessage(intlMessages.unsupported)}`} +
+ ); + } + + const onChange = (e: React.ChangeEvent) => { + const { value } = e.target; + setSpeechLocale(value); + }; + + return ( +
+ + +
+ ); +}; + +const AudioCaptionsSelectContainer: React.FC = () => { + const currentUser = useCurrentUser( + (user) => ({ + speechLocale: user.speechLocale, + voice: user.voice, + }), + ); + const isEnabled = isAudioTranscriptionEnabled(); + const voices = getSpeechVoices(); + + if (!currentUser || !isEnabled || !voices) return null; + + return ( + + ); +}; + +export default AudioCaptionsSelectContainer; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/live/component.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/live/component.tsx new file mode 100644 index 0000000000..cd3426dc47 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/live/component.tsx @@ -0,0 +1,108 @@ +import { useSubscription } from '@apollo/client'; +import { Meteor } from 'meteor/meteor'; +import React, { useEffect, useRef, useState } from 'react'; +import { GET_AUDIO_CAPTIONS, GetAudioCaptions } from './queries'; +import logger from '/imports/startup/client/logger'; +import { User } from '/imports/ui/Types/user'; +import Styled from './styles'; + +const CAPTIONS_CONFIG = Meteor.settings.public.captions; + +interface AudioCaptionsLiveProps { + transcript: string; + user: Pick; +} + +const AudioCaptionsLive: React.FC = ({ + transcript, + user, +}) => { + const [clear, setClear] = useState(true); + const timerRef = useRef | null>(null); + const prevTranscriptRef = useRef(''); + + const resetTimer = () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + // did update + useEffect(() => { + if (clear) { + if (prevTranscriptRef.current !== transcript) { + prevTranscriptRef.current = transcript; + setClear(false); + } + } else { + resetTimer(); + timerRef.current = setTimeout(() => setClear(true), CAPTIONS_CONFIG.time); + } + }, [transcript, clear]); + // will unmount + useEffect(() => () => resetTimer(), []); + + const hasContent = transcript.length > 0 && !clear; + + return ( + + {clear ? null : ( + + + {user.name.slice(0, 2)} + + + )} + + {clear ? '' : transcript} + + + {clear ? '' : transcript} + + + ); +}; + +const AudioCaptionsLiveContainer: React.FC = () => { + const { + data: AudioCaptionsLiveData, + loading: AudioCaptionsLiveLoading, + error: AudioCaptionsLiveError, + } = useSubscription(GET_AUDIO_CAPTIONS, { + variables: { + time: new Date().toISOString(), + }, + }); + + if (AudioCaptionsLiveLoading) return null; + + if (AudioCaptionsLiveError) { + logger.error(AudioCaptionsLiveError); + return ( +
+ {JSON.stringify(AudioCaptionsLiveError)} +
+ ); + } + + if (!AudioCaptionsLiveData) return null; + const { + transcript, + user, + } = AudioCaptionsLiveData.audio_caption[0]; + return ( + + ); +}; + +export default AudioCaptionsLiveContainer; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/live/queries.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/live/queries.ts new file mode 100644 index 0000000000..73803d63c6 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/live/queries.ts @@ -0,0 +1,34 @@ +import { gql } from '@apollo/client'; + +export interface GetAudioCaptions { + audio_caption: Array<{ + user: { + avatar: string; + color: string; + isModerator: boolean; + name: string; + }; + transcript: string; + transcriptId: string; + }>; +} + +export const GET_AUDIO_CAPTIONS = gql` + subscription MySubscription ($time: Date!){ + audio_caption(where: {createdAt: {_lte: $time}}, order_by: {createdAt: desc}, limit: 10) { + user { + avatar + color + isModerator + name + } + transcript + transcriptId + createdAt + } + } +`; + +export default { + GET_AUDIO_CAPTIONS, +}; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/live/styles.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/live/styles.ts new file mode 100644 index 0000000000..81bdc2073a --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/live/styles.ts @@ -0,0 +1,139 @@ +import styled from 'styled-components'; + +import { + userIndicatorsOffset, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + colorWhite, + userListBg, + colorSuccess, +} from '/imports/ui/stylesheets/styled-components/palette'; + +type CaptionsProps = { + hasContent: boolean; +}; + +interface UserAvatarProps { + color: string; + moderator: boolean; + avatar: string; + emoji?: string; +} + +const Wrapper = styled.div` + display: flex; +`; + +const Captions = styled.div` + white-space: pre-line; + word-wrap: break-word; + font-family: Verdana, Arial, Helvetica, sans-serif; + font-size: 1.5rem; + background: #000000a0; + color: white; + ${({ hasContent }) => hasContent && ` + padding: 0.5rem; + `} +`; + +const VisuallyHidden = styled.div` + position: absolute; + overflow: hidden; + clip: rect(0 0 0 0); + height: 1px; + width: 1px; + margin: -1px; + padding: 0; + border: 0; +`; + +const UserAvatarWrapper = styled.div` + background: #000000a0; + min-height: 3.25; + padding: 0.5rem; + text-transform: capitalize; + width: 3.25rem; +`; + +const UserAvatar = styled.div` + flex: 0 0 2.25rem; + margin: 0px calc(0.5rem) 0px 0px; + box-flex: 0; + position: relative; + height: 2.25rem; + width: 2.25rem; + border-radius: 50%; + text-align: center; + font-size: .85rem; + border: 2px solid transparent; + user-select: none; + ${ + ({ color }: UserAvatarProps) => ` + background-color: ${color}; + `} + } + &:after, + &:before { + content: ""; + position: absolute; + width: 0; + height: 0; + padding-top: .5rem; + padding-right: 0; + padding-left: 0; + padding-bottom: 0; + color: inherit; + top: auto; + left: auto; + bottom: ${userIndicatorsOffset}; + right: ${userIndicatorsOffset}; + border: 1.5px solid ${userListBg}; + border-radius: 50%; + background-color: ${colorSuccess}; + color: ${colorWhite}; + opacity: 0; + font-family: 'bbb-icons'; + font-size: .65rem; + line-height: 0; + text-align: center; + vertical-align: middle; + letter-spacing: -.65rem; + z-index: 1; + [dir="rtl"] & { + left: ${userIndicatorsOffset}; + right: auto; + padding-right: .65rem; + padding-left: 0; + } + } + ${({ moderator }: UserAvatarProps) => moderator && ` + border-radius: 5px; + `} + // ================ image ================ + ${({ avatar, emoji }: UserAvatarProps) => avatar?.length !== 0 && !emoji && ` + background-image: url(${avatar}); + background-repeat: no-repeat; + background-size: contain; + `} + // ================ image ================ + // ================ content ================ + color: ${colorWhite}; + font-size: 110%; + text-transform: capitalize; + display: flex; + justify-content: center; + align-items:center; + // ================ content ================ + & .react-loading-skeleton { + height: 2.25rem; + width: 2.25rem; + } +`; + +export default { + Wrapper, + Captions, + VisuallyHidden, + UserAvatarWrapper, + UserAvatar, +}; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/service.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/service.ts new file mode 100644 index 0000000000..c1070c92aa --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/service.ts @@ -0,0 +1,55 @@ +import { Meteor } from 'meteor/meteor'; +import { unique } from 'radash'; +import { makeCall } from '/imports/ui/services/api'; +import logger from '/imports/startup/client/logger'; +import { setAudioCaptionEnable } from '/imports/ui/core/local-states/useAudioCaptionEnable'; +import { isLiveTranscriptionEnabled } from '/imports/ui/services/features'; + +const CONFIG = Meteor.settings.public.app.audioCaptions; +const PROVIDER = CONFIG.provider; +const LANGUAGES = CONFIG.language.available; + +export const isAudioTranscriptionEnabled = () => isLiveTranscriptionEnabled(); + +export const isWebSpeechApi = () => PROVIDER === 'webspeech'; + +export const getSpeechVoices = () => { + if (!isWebSpeechApi()) return LANGUAGES; + + return unique( + window + .speechSynthesis + .getVoices() + .map((v) => v.lang) + .filter((v) => LANGUAGES.includes(v)), + ); +}; + +export const setAudioCaptions = (value: boolean) => { + setAudioCaptionEnable(value); + // @ts-ignore - Exist while we have meteor in the project + Session.set('audioCaptions', value); +}; + +export const setSpeechLocale = (value: string) => { + const voices = getSpeechVoices(); + + if (voices.includes(value) || value === '') { + makeCall('setSpeechLocale', value, CONFIG.provider); + } else { + logger.error({ + logCode: 'captions_speech_locale', + }, 'Captions speech set locale error'); + } +}; + +export const useFixedLocale = () => isAudioTranscriptionEnabled() && CONFIG.language.forceLocale; + +export default { + getSpeechVoices, + isAudioTranscriptionEnabled, + setSpeechLocale, + setAudioCaptions, + isWebSpeechApi, + useFixedLocale, +}; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/speech/component.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/speech/component.tsx new file mode 100644 index 0000000000..8b95ab9607 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/speech/component.tsx @@ -0,0 +1,170 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { + SpeechRecognitionAPI, + generateId, + initSpeechRecognition, + isLocaleValid, + updateFinalTranscript, + updateInterimTranscript, +} from './service'; +import logger from '/imports/startup/client/logger'; +import { useReactiveVar } from '@apollo/client'; +import AudioManager from '/imports/ui/services/audio-manager'; +import { useCurrentUser } from '/imports/ui/core/hooks/useCurrentUser'; + +type SpeechRecognitionEvent = { + resultIndex: number; + results: SpeechRecognitionResult[]; +} + +type SpeechRecognitionErrorEvent = { + error: string; + message: string; +} + +interface AudioCaptionsSpeechProps { + locale: string; + connected: boolean; +} +const speechHasStarted = { + started: false, +}; +const AudioCaptionsSpeech: React.FC = ({ + locale, + connected, +}) => { + const resultRef = useRef({ + id: generateId(), + transcript: '', + isFinal: true, + }); + + const idleRef = useRef(true); + const speechRecognitionRef = useRef>(null); + const onEnd = useCallback(() => { + stop(); + }, []); + const onError = useCallback((event: SpeechRecognitionErrorEvent) => { + stop(); + logger.error({ + logCode: 'captions_speech_recognition', + extraInfo: { + error: event.error, + message: event.message, + }, + }, 'Captions speech recognition error'); + }, []); + + const onResult = useCallback((event: SpeechRecognitionEvent) => { + const { + resultIndex, + results, + } = event; + + const { id } = resultRef.current; + + const { transcript } = results[resultIndex][0]; + const { isFinal } = results[resultIndex]; + + resultRef.current.transcript = transcript; + resultRef.current.isFinal = isFinal; + + if (isFinal) { + updateFinalTranscript(id, transcript, locale); + resultRef.current.id = generateId(); + } else { + updateInterimTranscript(id, transcript, locale); + } + }, [locale]); + + const stop = useCallback(() => { + idleRef.current = true; + if (speechRecognitionRef.current) { + const { + isFinal, + transcript, + } = resultRef.current; + + if (!isFinal) { + const { id } = resultRef.current; + updateFinalTranscript(id, transcript, locale); + speechRecognitionRef.current.abort(); + } else { + speechRecognitionRef.current.stop(); + speechHasStarted.started = false; + } + } + }, [locale]); + + const start = (settedLocale: string) => { + if (speechRecognitionRef.current && isLocaleValid(settedLocale)) { + speechRecognitionRef.current.lang = settedLocale; + try { + resultRef.current.id = generateId(); + speechRecognitionRef.current.start(); + idleRef.current = false; + } catch (event: unknown) { + onError(event as SpeechRecognitionErrorEvent); + } + } + }; + + useEffect(() => { + speechRecognitionRef.current = initSpeechRecognition(); + }, []); + + useEffect(() => { + if (speechRecognitionRef.current) { + speechRecognitionRef.current.onend = () => onEnd(); + speechRecognitionRef.current.onerror = (event: SpeechRecognitionErrorEvent) => onError(event); + speechRecognitionRef.current.onresult = (event: SpeechRecognitionEvent) => onResult(event); + } + }, [speechRecognitionRef.current]); + + const connectedRef = useRef(connected); + const localeRef = useRef(locale); + useEffect(() => { + // Connected + if (!connectedRef.current && connected) { + start(locale); + connectedRef.current = connected; + } else if (connectedRef.current && !connected) { + // Disconnected + stop(); + connectedRef.current = connected; + } else if (localeRef.current !== locale) { + // Locale changed + if (connectedRef.current && connected) { + stop(); + start(locale); + localeRef.current = locale; + } + } + }, [connected, locale]); + + return null; +}; + +const AudioCaptionsSpeechContainer: React.FC = () => { + /* eslint no-underscore-dangle: 0 */ + // @ts-ignore - temporary while hybrid (meteor+GraphQl) + const isConnected = useReactiveVar(AudioManager._isConnected.value) as boolean; + + const currentUser = useCurrentUser( + (user) => ({ + speechLocale: user.speechLocale, + voice: user.voice, + }), + ); + + if (!currentUser) return null; + + return ( + + ); +}; + +export default AudioCaptionsSpeechContainer; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/speech/service.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/speech/service.ts new file mode 100644 index 0000000000..a5f2e9e998 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/speech/service.ts @@ -0,0 +1,121 @@ +import { Meteor } from 'meteor/meteor'; +import { isAudioTranscriptionEnabled, isWebSpeechApi, setSpeechLocale } from '../service'; +import Auth from '/imports/ui/services/auth'; +import deviceInfo from '/imports/utils/deviceInfo'; +import { unique } from 'radash'; +// @ts-ignore - bbb-diff is not typed +import { diff } from '@mconf/bbb-diff'; +import { Session } from 'meteor/session'; +import { throttle } from '/imports/utils/throttle'; +import { makeCall } from '/imports/ui/services/api'; + +const CONFIG = Meteor.settings.public.app.audioCaptions; +const LANGUAGES = CONFIG.language.available; +const VALID_ENVIRONMENT = !deviceInfo.isMobile || CONFIG.mobile; +const THROTTLE_TIMEOUT = 2000; +// Reason: SpeechRecognition is not in window type definition +// Fix based on: https://stackoverflow.com/questions/41740683/speechrecognition-and-speechsynthesis-in-typescript +/* eslint @typescript-eslint/no-explicit-any: 0 */ +export const SpeechRecognitionAPI = (window as any).SpeechRecognition +|| (window as any).webkitSpeechRecognition; + +export const generateId = () => `${Auth.userID}-${Date.now()}`; + +export const hasSpeechRecognitionSupport = () => typeof SpeechRecognitionAPI !== 'undefined' + && typeof window.speechSynthesis !== 'undefined' + && VALID_ENVIRONMENT; + +export const setSpeechVoices = () => { + if (!hasSpeechRecognitionSupport()) return; + + Session.set('speechVoices', unique(window.speechSynthesis.getVoices().map((v) => v.lang))); +}; + +export const useFixedLocale = () => isAudioTranscriptionEnabled() && CONFIG.language.forceLocale; + +export const localeAsDefaultSelected = () => CONFIG.language.defaultSelectLocale; + +export const getLocale = () => { + const { locale } = CONFIG.language; + if (locale === 'browserLanguage') return navigator.language; + if (locale === 'disabled') return ''; + return locale; +}; + +let prevId: string = ''; +let prevTranscript: string = ''; +const updateTranscript = ( + id: string, + transcript: string, + locale: string, + isFinal: boolean, +) => { + // If it's a new sentence + if (id !== prevId) { + prevId = id; + prevTranscript = ''; + } + + const transcriptDiff = diff(prevTranscript, transcript); + + let start = 0; + let end = 0; + let text = ''; + if (transcriptDiff) { + start = transcriptDiff.start; + end = transcriptDiff.end; + text = transcriptDiff.text; + } + + // Stores current transcript as previous + prevTranscript = transcript; + + makeCall('updateTranscript', id, start, end, text, transcript, locale, isFinal); +}; + +const throttledTranscriptUpdate = throttle(updateTranscript, THROTTLE_TIMEOUT, { + leading: false, + trailing: true, +}); + +export const updateInterimTranscript = (id: string, transcript: string, locale: string) => { + throttledTranscriptUpdate(id, transcript, locale, false); +}; + +export const updateFinalTranscript = (id: string, transcript: string, locale: string) => { + throttledTranscriptUpdate.cancel(); + updateTranscript(id, transcript, locale, true); +}; + +export const initSpeechRecognition = () => { + if (!isAudioTranscriptionEnabled() && !isWebSpeechApi()) return null; + + if (!hasSpeechRecognitionSupport()) return null; + + setSpeechVoices(); + const speechRecognition = new SpeechRecognitionAPI(); + + speechRecognition.continuous = true; + speechRecognition.interimResults = true; + + if (useFixedLocale() || localeAsDefaultSelected()) { + setSpeechLocale(getLocale()); + } else { + setSpeechLocale(navigator.language); + } + + return speechRecognition; +}; + +export const isLocaleValid = (locale: string) => LANGUAGES.includes(locale); + +export default { + generateId, + initSpeechRecognition, + getLocale, + localeAsDefaultSelected, + useFixedLocale, + setSpeechVoices, + hasSpeechRecognitionSupport, + isLocaleValid, +}; diff --git a/bigbluebutton-html5/imports/ui/components/audio/captions/button/container.jsx b/bigbluebutton-html5/imports/ui/components/audio/captions/button/container.jsx index 44a08b88c9..11fc878ad3 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/captions/button/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/captions/button/container.jsx @@ -4,10 +4,11 @@ import Service from '/imports/ui/components/audio/captions/service'; import Button from './component'; import SpeechService from '/imports/ui/components/audio/captions/speech/service'; import AudioService from '/imports/ui/components/audio/service'; +import AudioCaptionsButtonContainer from '../../audio-graphql/audio-captions/button/component'; const Container = (props) => + + } else if(curr.isOnline) { + return
+ {curr.meeting.name} +

+ You are online, welcome {curr.name} ({curr.userId}) + + + {/**/} + {/*
*/} + + {/**/} + {/*
*/} + + + + + + +
+ + +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + // return ( + // + // {curr.userId} + // {curr.name} + // {curr.joined && !curr.loggedOut && !curr.ejected ? 'joined' : ''} + // {curr.loggedOut ? 'loggedOut' : ''} + // {curr.ejected ? 'ejected' : ''} + // {!curr.joined && !curr.loggedOut ? : ''} + // {curr.joined && !curr.loggedOut && !curr.ejected ? : ''} + // + // {curr.joinErrorCode} + // {curr.joinErrorMessage} + // + // ); + } + + + })} + ) + } +} + diff --git a/bbb-graphql-client-test/src/ChatMessages.js b/bbb-graphql-client-test/src/ChatMessages.js index eb938c4025..780bd6f23a 100644 --- a/bbb-graphql-client-test/src/ChatMessages.js +++ b/bbb-graphql-client-test/src/ChatMessages.js @@ -25,12 +25,11 @@ export default function ChatMessages({userId}) { const { loading, error, data } = usePatchedSubscription( gql`subscription { - chat_message_private(limit: 20, order_by: [{createdTime: desc}, {senderName: asc}]) { + chat_message_private(limit: 20, order_by: [{createdAt: desc}, {senderName: asc}]) { chatEmphasizedText chatId correlationId - createdTime - createdTimeAsDate + createdAt message messageId senderId @@ -63,7 +62,7 @@ export default function ChatMessages({userId}) { {curr.chatId} {curr.senderName} {curr.message} - {curr.createdTimeAsDate} ({curr.createdTime}) + {curr.createdAt} { curr.senderId !== userId ? diff --git a/bbb-graphql-client-test/src/ChatPublicMessages.js b/bbb-graphql-client-test/src/ChatPublicMessages.js index e5860c669a..0fd0f46e30 100644 --- a/bbb-graphql-client-test/src/ChatPublicMessages.js +++ b/bbb-graphql-client-test/src/ChatPublicMessages.js @@ -1,7 +1,10 @@ import {useSubscription, gql, useMutation} from '@apollo/client'; import usePatchedSubscription from "./usePatchedSubscription"; +import React, {useState} from "react"; export default function ChatPublicMessages({userId}) { + const [textAreaValue, setTextAreaValue] = useState(``); + const [updateLastSeen] = useMutation(gql` mutation UpdateChatUser($chatId: String, $lastSeenAt: bigint) { update_chat_user( @@ -23,14 +26,36 @@ export default function ChatPublicMessages({userId}) { }; + const [sendChatMessage] = useMutation(gql` + mutation ChatSendMessage($chatId: String!, $message: String!) { + chatSendMessage( + chatId: $chatId, + chatMessageInMarkdownFormat: $message + ) + } + `); + + const handleSendMessage = () => { + if (textAreaValue.trim() !== '') { + sendChatMessage({ + variables: { + chatId: 'MAIN-PUBLIC-GROUP-CHAT', + message: textAreaValue, + }, + }); + + setTextAreaValue(''); + } + }; + + const { loading, error, data } = usePatchedSubscription( gql`subscription { - chat_message_public(limit: 20, order_by: {createdTime: desc}) { + chat_message_public(limit: 20, order_by: {createdAt: desc}) { chatId chatEmphasizedText correlationId - createdTime - createdTimeAsDate + createdAt message messageId senderId @@ -42,7 +67,7 @@ export default function ChatPublicMessages({userId}) { return !loading && !error && ( - + @@ -53,27 +78,40 @@ export default function ChatPublicMessages({userId}) { - - + + {data.map((curr) => { console.log('message', curr); - return ( - - - - - - - - ); + return ( + + + + + + + + ); })} - + + + + + +
Public Chat Messages
Sent At
{curr.chatId}{curr.senderName}{curr.message}{curr.createdTimeAsDate} ({curr.createdTime}) - { - curr.senderId !== userId ? - - : '' - } -
{curr.chatId}{curr.senderName}{curr.message}{curr.createdAt} + { + curr.senderId !== userId ? + + : '' + } +
+ + +
); } diff --git a/bbb-graphql-client-test/src/CursorsAll.js b/bbb-graphql-client-test/src/CursorsAll.js index f684bb3e52..4b34323be3 100644 --- a/bbb-graphql-client-test/src/CursorsAll.js +++ b/bbb-graphql-client-test/src/CursorsAll.js @@ -24,7 +24,7 @@ export default function CursorsAll() { ( - + diff --git a/bbb-graphql-client-test/src/MeetingInfo.js b/bbb-graphql-client-test/src/MeetingInfo.js index 07c84d9f39..d4d1992668 100644 --- a/bbb-graphql-client-test/src/MeetingInfo.js +++ b/bbb-graphql-client-test/src/MeetingInfo.js @@ -7,7 +7,7 @@ export default function MeetingInfo() { meetingId createdTime disabledFeatures - duration + durationInSeconds extId html5InstanceId isBreakout @@ -31,7 +31,7 @@ export default function MeetingInfo() { {/**/} - + @@ -42,7 +42,7 @@ export default function MeetingInfo() { {/**/} - + ); })} diff --git a/bbb-graphql-client-test/src/MyInfo.js b/bbb-graphql-client-test/src/MyInfo.js index ea61db4af5..10525d789b 100644 --- a/bbb-graphql-client-test/src/MyInfo.js +++ b/bbb-graphql-client-test/src/MyInfo.js @@ -22,12 +22,13 @@ export default function MyInfo({userAuthToken}) { const [dispatchUserJoin] = useMutation(gql` mutation UserJoin($authToken: String!, $clientType: String!) { - userJoin( + userJoinMeeting( authToken: $authToken, clientType: $clientType, ) } `); + const handleDispatchUserJoin = (authToken) => { dispatchUserJoin({ variables: { @@ -37,12 +38,23 @@ export default function MyInfo({userAuthToken}) { }); }; + const [dispatchUserLeave] = useMutation(gql` + mutation UserLeaveMeeting { + userLeaveMeeting + } + `); + const handleDispatchUserLeave = (authToken) => { + dispatchUserLeave(); + }; + const { loading, error, data } = useSubscription( gql`subscription { user_current { userId name + loggedOut + ejected joined joinErrorCode joinErrorMessage @@ -60,7 +72,7 @@ export default function MyInfo({userAuthToken}) { {/**/} - + @@ -72,8 +84,11 @@ export default function MyInfo({userAuthToken}) { - diff --git a/bbb-graphql-client-test/src/UserConnectionStatus.js b/bbb-graphql-client-test/src/UserConnectionStatus.js index 2ed6bfc1ec..cc5ed9c404 100644 --- a/bbb-graphql-client-test/src/UserConnectionStatus.js +++ b/bbb-graphql-client-test/src/UserConnectionStatus.js @@ -52,7 +52,7 @@ export default function UserConnectionStatus() { setTimeout(() => { handleUpdateConnectionAliveAt(); - }, 5000); + }, 25000); }; useEffect(() => { diff --git a/bbb-graphql-client-test/src/UserList.js b/bbb-graphql-client-test/src/UserList.js index 38af55611a..64bdaafccb 100644 --- a/bbb-graphql-client-test/src/UserList.js +++ b/bbb-graphql-client-test/src/UserList.js @@ -1,9 +1,10 @@ -import {gql} from '@apollo/client'; +import {gql, useMutation} from '@apollo/client'; import React, { useState } from "react"; import usePatchedSubscription from "./usePatchedSubscription"; -const ParentOfUserList = ({userId}) => { +const ParentOfUserList = ({user}) => { const [shouldRender, setShouldRender] = useState(true); + return (
Userlist: @@ -13,12 +14,30 @@ const ParentOfUserList = ({userId}) => { setShouldRender(e.target.checked); } }> - {shouldRender && } + {shouldRender && }
); } function UserList({userId}) { + + const [dispatchUserEject] = useMutation(gql` + mutation UserEject($userId: String!) { + userEjectFromMeeting( + userId: $userId, + banUser: false, + ) + } + `); + + const handleDispatchUserEject = (userId) => { + dispatchUserEject({ + variables: { + userId: userId, + }, + }); + }; + const { loading, error, data } = usePatchedSubscription( gql`subscription { user(limit: 50, order_by: [ @@ -120,7 +139,9 @@ function UserList({userId}) { - + ); })} diff --git a/bbb-graphql-server/bbb_schema.sql b/bbb-graphql-server/bbb_schema.sql index 4f2ca1f10d..ebbef3916e 100644 --- a/bbb-graphql-server/bbb_schema.sql +++ b/bbb-graphql-server/bbb_schema.sql @@ -233,6 +233,7 @@ CREATE TABLE "user" ( "avatar" varchar(500), "color" varchar(7), "sessionToken" varchar(16), + "authToken" varchar(16), "authed" bool, "joined" bool, "joinErrorCode" varchar(50), @@ -384,6 +385,7 @@ CREATE INDEX "idx_v_user_meetingId_orderByColumns" ON "user"("meetingId","role", CREATE OR REPLACE VIEW "v_user_current" AS SELECT "user"."userId", "user"."extId", + "user"."authToken", "user"."meetingId", "user"."name", "user"."nameSortable", @@ -419,7 +421,8 @@ AS SELECT "user"."userId", "user"."hasDrawPermissionOnCurrentPage", "user"."echoTestRunningAt", CASE WHEN "user"."echoTestRunningAt" > current_timestamp - INTERVAL '3 seconds' THEN TRUE ELSE FALSE END "isRunningEchoTest", - CASE WHEN "user"."role" = 'MODERATOR' THEN true ELSE false END "isModerator" + CASE WHEN "user"."role" = 'MODERATOR' THEN true ELSE false END "isModerator", + CASE WHEN "user"."joined" IS true AND "user"."expired" IS false AND "user"."loggedOut" IS false AND "user"."ejected" IS NOT TRUE THEN true ELSE false END "isOnline" FROM "user"; CREATE OR REPLACE VIEW "v_user_guest" AS diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_current.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_current.yaml index 90f43241b5..993b22335d 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_current.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_current.yaml @@ -130,6 +130,7 @@ select_permissions: - role: bbb_client permission: columns: + - authToken - authed - avatar - away @@ -150,6 +151,7 @@ select_permissions: - hasDrawPermissionOnCurrentPage - isDialIn - isModerator + - isOnline - isRunningEchoTest - joinErrorCode - joinErrorMessage @@ -173,6 +175,7 @@ select_permissions: - role: pre_join_bbb_client permission: columns: + - authToken - authed - banned - color @@ -181,6 +184,8 @@ select_permissions: - ejectReasonCode - ejected - expired + - isOnline + - isModerator - extId - guest - guestStatus diff --git a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ConnectionController.groovy b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ConnectionController.groovy index fb9b81a2a6..88c07e3776 100755 --- a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ConnectionController.groovy +++ b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ConnectionController.groovy @@ -95,7 +95,7 @@ class ConnectionController { def builder = new JsonBuilder() builder { "response" "authorized" - "X-Hasura-Role" u ? "bbb_client" : "pre_join_bbb_client" + "X-Hasura-Role" u && !u.hasLeft() ? "bbb_client" : "pre_join_bbb_client" "X-Hasura-ModeratorInMeeting" u && u.isModerator() ? userSession.meetingID : "" "X-Hasura-PresenterInMeeting" u && u.isPresenter() ? userSession.meetingID : "" "X-Hasura-UserId" userSession.internalUserId From a4de358c48071a20e5eaeec9e5fd74b0c12200ea Mon Sep 17 00:00:00 2001 From: Tainan Felipe Date: Thu, 18 Jan 2024 13:09:33 -0300 Subject: [PATCH 0168/1039] Fix: Remove unnecessary component --- bigbluebutton-html5/imports/ui/components/app/component.jsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/app/component.jsx b/bigbluebutton-html5/imports/ui/components/app/component.jsx index 444c57589d..a39f3ef238 100644 --- a/bigbluebutton-html5/imports/ui/components/app/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/app/component.jsx @@ -631,11 +631,6 @@ class App extends Component { {shouldShowPresentation ? : null} {shouldShowScreenshare ? : null} - { - shouldShowExternalVideo - ? - : null - } {shouldShowSharedNotes ? ( Date: Thu, 18 Jan 2024 13:21:57 -0300 Subject: [PATCH 0169/1039] Remove console.log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ramón Souza --- bigbluebutton-html5/imports/ui/services/auth/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/bigbluebutton-html5/imports/ui/services/auth/index.js b/bigbluebutton-html5/imports/ui/services/auth/index.js index 604a40353d..d6617e2a7f 100755 --- a/bigbluebutton-html5/imports/ui/services/auth/index.js +++ b/bigbluebutton-html5/imports/ui/services/auth/index.js @@ -23,7 +23,6 @@ class Auth { return; } - console.log('-----------------'); this._meetingID = Storage.getItem('meetingID'); this._userID = Storage.getItem('userID'); From 961fb1940418aaffa8513da2a2acfabb3f6176a0 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Thu, 18 Jan 2024 16:40:09 -0300 Subject: [PATCH 0170/1039] Provide sharedNotes.diff through Graphql --- bbb-graphql-server/bbb_schema.sql | 5 +++++ .../tables/public_v_sharedNotes_diff.yaml | 22 +++++++++++++++++++ .../BigBlueButton/tables/tables.yaml | 1 + 3 files changed, 28 insertions(+) create mode 100644 bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_sharedNotes_diff.yaml diff --git a/bbb-graphql-server/bbb_schema.sql b/bbb-graphql-server/bbb_schema.sql index ab2da6a7a1..6d8360a18e 100644 --- a/bbb-graphql-server/bbb_schema.sql +++ b/bbb-graphql-server/bbb_schema.sql @@ -1500,6 +1500,11 @@ create table "sharedNotes_rev" ( ); --create view "v_sharedNotes_rev" as select * from "sharedNotes_rev"; +create view "v_sharedNotes_diff" as +select "meetingId", "sharedNotesExtId", "userId", "start", "end", "diff" +from "sharedNotes_rev" +where "diff" is not null; + create table "sharedNotes_session" ( "meetingId" varchar(100) references "meeting"("meetingId") ON DELETE CASCADE, "sharedNotesExtId" varchar(25), diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_sharedNotes_diff.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_sharedNotes_diff.yaml new file mode 100644 index 0000000000..b4d4dc6579 --- /dev/null +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_sharedNotes_diff.yaml @@ -0,0 +1,22 @@ +table: + name: v_sharedNotes_diff + schema: public +configuration: + column_config: {} + custom_column_names: {} + custom_name: sharedNotes_diff + custom_root_fields: {} +select_permissions: + - role: bbb_client + permission: + columns: + - diff + - end + - rev + - sharedNotesExtId + - start + - userId + filter: + meetingId: + _eq: X-Hasura-MeetingId + comment: "" diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/tables.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/tables.yaml index ba77948367..27edd90911 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/tables.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/tables.yaml @@ -36,6 +36,7 @@ - "!include public_v_pres_presentation_uploadToken.yaml" - "!include public_v_screenshare.yaml" - "!include public_v_sharedNotes.yaml" +- "!include public_v_sharedNotes_diff.yaml" - "!include public_v_sharedNotes_session.yaml" - "!include public_v_timer.yaml" - "!include public_v_user.yaml" From 32b239459c642a0c668f6f7a7fb5f76caf8796ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Thu, 18 Jan 2024 17:20:28 -0300 Subject: [PATCH 0171/1039] fix reset active timer --- .../imports/ui/components/timer/component.jsx | 20 +++++++++++++++---- .../timer-graphql/indicator/component.tsx | 6 ++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/timer/component.jsx b/bigbluebutton-html5/imports/ui/components/timer/component.jsx index 0c1754d834..4c6bc9e4ac 100644 --- a/bigbluebutton-html5/imports/ui/components/timer/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/timer/component.jsx @@ -120,10 +120,20 @@ class Timer extends Component { } handleControlClick() { - const { timer, startTimer, stopTimer } = this.props; + const { + timer, startTimer, stopTimer, timeOffset, + } = this.props; + + const { + running, + accumulated, + timestamp, + } = timer; if (timer.running) { - stopTimer(this.getTime()); + const elapsedTime = Service.getElapsedTime(running, timestamp, timeOffset, accumulated); + + stopTimer(elapsedTime); } else { startTimer(); } @@ -160,17 +170,19 @@ class Timer extends Component { } handleSwitchToStopwatch() { - const { timer, switchTimer } = this.props; + const { timer, stopTimer, switchTimer } = this.props; if (!timer.stopwatch) { + stopTimer(this.getTime()); switchTimer(true); } } handleSwitchToTimer() { - const { timer, switchTimer } = this.props; + const { timer, stopTimer, switchTimer } = this.props; if (timer.stopwatch) { + stopTimer(this.getTime()); switchTimer(false); } } diff --git a/bigbluebutton-html5/imports/ui/components/timer/timer-graphql/indicator/component.tsx b/bigbluebutton-html5/imports/ui/components/timer/timer-graphql/indicator/component.tsx index c10be4ec97..17b4f64584 100644 --- a/bigbluebutton-html5/imports/ui/components/timer/timer-graphql/indicator/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/timer/timer-graphql/indicator/component.tsx @@ -107,6 +107,8 @@ const TimerIndicator: React.FC = ({ }, [running]); useEffect(() => { + if (!running) return; + const timePassed = passedTime >= 0 ? passedTime : 0; setTime((prev) => { @@ -114,7 +116,7 @@ const TimerIndicator: React.FC = ({ if (timePassed > prev) return timePassed; return prev; }); - }, [passedTime, stopwatch]); + }, [passedTime, stopwatch, startedAt]); useEffect(() => { if (!timeRef.current) { @@ -188,7 +190,7 @@ const TimerIndicatorContainer: React.FC = () => { const { timer } = timerData; const [currentTimer] = timer; - if (!currentTimer.active) return null; + if (!currentTimer?.active) return null; const { accumulated, From 82aa24164ac76fbb085626b33b889803bce92ac6 Mon Sep 17 00:00:00 2001 From: Paulo Lanzarin <4529051+prlanzarin@users.noreply.github.com> Date: Thu, 18 Jan 2024 17:45:13 -0300 Subject: [PATCH 0172/1039] build(bbb-webrtc-recorder): v0.6.0 * v0.6.0 * feat: recorder.writeToDevNull option to write files to /dev/null (testing) * fix: panic due to negative seqnums in sequence unwrapper --- bbb-webrtc-recorder.placeholder.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbb-webrtc-recorder.placeholder.sh b/bbb-webrtc-recorder.placeholder.sh index e5ca651630..d30c36f9ac 100755 --- a/bbb-webrtc-recorder.placeholder.sh +++ b/bbb-webrtc-recorder.placeholder.sh @@ -1 +1 @@ -git clone --branch v0.5.2 --depth 1 https://github.com/bigbluebutton/bbb-webrtc-recorder bbb-webrtc-recorder +git clone --branch v0.6.0 --depth 1 https://github.com/bigbluebutton/bbb-webrtc-recorder bbb-webrtc-recorder From 62cf89cf266e0ea7dae47ef2583a2a09dd9726c3 Mon Sep 17 00:00:00 2001 From: Paulo Lanzarin <4529051+prlanzarin@users.noreply.github.com> Date: Thu, 18 Jan 2024 17:47:06 -0300 Subject: [PATCH 0173/1039] build(bbb-webhooks): v3.0.0-beta.4 * fix: use ISO timestamps in production logs * chore: remove unused events * `rap-published`, `rap-unpublished`, `rap-deleted` * chore: support internal_meeting_id != record_id on rap events * !fix(webhooks): remove general getRaw configuration * fix(test): use redisUrl for node-redis client configuration * fix(test): pick up mocha configs via new .mocharc.yml file * build: set .nvmrc to lts/iron (Node.js 20) --- bbb-webhooks.placeholder.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbb-webhooks.placeholder.sh b/bbb-webhooks.placeholder.sh index 9f4dbf861f..b69a58b4ec 100755 --- a/bbb-webhooks.placeholder.sh +++ b/bbb-webhooks.placeholder.sh @@ -1 +1 @@ -git clone --branch v3.0.0-beta.3 --depth 1 https://github.com/bigbluebutton/bbb-webhooks bbb-webhooks +git clone --branch v3.0.0-beta.4 --depth 1 https://github.com/bigbluebutton/bbb-webhooks bbb-webhooks From da31a8d0f7b557909ff1b1c9dadce0157ce11923 Mon Sep 17 00:00:00 2001 From: Paulo Lanzarin <4529051+prlanzarin@users.noreply.github.com> Date: Thu, 18 Jan 2024 17:52:21 -0300 Subject: [PATCH 0174/1039] build(bbb-webrtc-sfu): v2.13.0-alpha.1 * feat: add inbound queue size and job failure metrics * feat: add dry-run recording mode * feat: add time_to_mute/unmute metrics * fix(audio): log and track metrics for hold/unhold timeouts * fix(bbb-webrtc-recorder): exception when removing nullish recording callbacks * fix(mediasoup): check for null producers * fix(screenshare): resolve subscriberAnswer job * fix(audio): prevent false positives in TLO toggle metrics * refactor: replace logger lib, Winston -> Pino * Winston's been problematic (missing logs) and Pino is more performant * build: bump Docker and nvmrc to Node.js 20 (LTS) --- bbb-webrtc-sfu.placeholder.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbb-webrtc-sfu.placeholder.sh b/bbb-webrtc-sfu.placeholder.sh index c1b39d78a9..eb42fb30e0 100755 --- a/bbb-webrtc-sfu.placeholder.sh +++ b/bbb-webrtc-sfu.placeholder.sh @@ -1 +1 @@ -git clone --branch v2.12.1 --depth 1 https://github.com/bigbluebutton/bbb-webrtc-sfu bbb-webrtc-sfu +git clone --branch v2.13.0-alpha.1 --depth 1 https://github.com/bigbluebutton/bbb-webrtc-sfu bbb-webrtc-sfu From 535703b644e54685e16587f4e1210208f0feb1df Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Thu, 18 Jan 2024 23:25:44 -0300 Subject: [PATCH 0175/1039] Add missing column to sharedNotes_diff --- bbb-graphql-server/bbb_schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbb-graphql-server/bbb_schema.sql b/bbb-graphql-server/bbb_schema.sql index 6d8360a18e..aa87f66697 100644 --- a/bbb-graphql-server/bbb_schema.sql +++ b/bbb-graphql-server/bbb_schema.sql @@ -1501,7 +1501,7 @@ create table "sharedNotes_rev" ( --create view "v_sharedNotes_rev" as select * from "sharedNotes_rev"; create view "v_sharedNotes_diff" as -select "meetingId", "sharedNotesExtId", "userId", "start", "end", "diff" +select "meetingId", "sharedNotesExtId", "userId", "start", "end", "diff", "rev" from "sharedNotes_rev" where "diff" is not null; From 5118d1f68b3c9786f26874a7907a80f2c25ecd07 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Thu, 18 Jan 2024 23:31:06 -0300 Subject: [PATCH 0176/1039] Add actions to delete/reset plugin data channel --- .../org/bigbluebutton/ClientSettings.scala | 36 ++++++++-- ...luginDataChannelDeleteMessageMsgHdlr.scala | 60 +++++++++++++++++ ...inDataChannelDispatchMessageMsgHdlr.scala} | 4 +- .../PluginDataChannelResetMsgHdlr.scala | 50 ++++++++++++++ .../core/apps/plugin/PluginHdlrs.scala | 4 +- .../core/db/PluginDataChannelMessageDAO.scala | 58 +++++++++++++++- .../senders/ReceivedJsonMsgHandlerActor.scala | 10 ++- .../core/running/MeetingActor.scala | 4 +- .../common2/msgs/PluginMsgs.scala | 22 ++++++- .../actions/pluginDataChannelDeleteMessage.ts | 25 +++++++ ...ts => pluginDataChannelDispatchMessage.ts} | 2 +- .../src/actions/pluginDataChannelReset.ts | 24 +++++++ .../src/PluginDataChannel.js | 66 +++++++++++++++---- bbb-graphql-server/bbb_schema.sql | 10 +-- bbb-graphql-server/metadata/actions.graphql | 35 +++++++--- bbb-graphql-server/metadata/actions.yaml | 24 +++++-- 16 files changed, 383 insertions(+), 51 deletions(-) create mode 100755 akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginDataChannelDeleteMessageMsgHdlr.scala rename akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/{DispatchPluginDataChannelMessageMsgHdlr.scala => PluginDataChannelDispatchMessageMsgHdlr.scala} (92%) create mode 100755 akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginDataChannelResetMsgHdlr.scala create mode 100644 bbb-graphql-actions/src/actions/pluginDataChannelDeleteMessage.ts rename bbb-graphql-actions/src/actions/{dispatchPluginDataChannelMessageMsg.ts => pluginDataChannelDispatchMessage.ts} (93%) create mode 100644 bbb-graphql-actions/src/actions/pluginDataChannelReset.ts diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/ClientSettings.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/ClientSettings.scala index ce9edb046b..53000a6507 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/ClientSettings.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/ClientSettings.scala @@ -117,13 +117,37 @@ object ClientSettings extends SystemConfiguration { for { dataChannel <- dataChannels } yield { - if (dataChannel.contains("name") && dataChannel.contains("writePermission")) { + if (dataChannel.contains("name")) { val channelName = dataChannel("name").toString - val writePermission = dataChannel("writePermission") - writePermission match { - case wPerm: List[String] => pluginDataChannels += (channelName -> DataChannel(channelName, wPerm)) - case _ => logger.warn(s"Invalid writePermission for channel $channelName in plugin $pluginName") + val writePermission = { + if (dataChannel.contains("writePermission")) { + dataChannel("writePermission") match { + case wPerm: List[String] => wPerm + case _ => { + logger.warn(s"Invalid writePermission for channel $channelName in plugin $pluginName") + List() + } + } + } else { + logger.warn(s"Missing config writePermission for channel $channelName in plugin $pluginName") + List() + } } + val deletePermission = { + if (dataChannel.contains("deletePermission")) { + dataChannel("deletePermission") match { + case dPerm: List[String] => dPerm + case _ => { + logger.warn(s"Invalid deletePermission for channel $channelName in plugin $pluginName") + List() + } + } + } else { + List() + } + } + + pluginDataChannels += (channelName -> DataChannel(channelName, writePermission, deletePermission)) } } case _ => logger.warn(s"Plugin $pluginName has an invalid dataChannels format") @@ -139,7 +163,7 @@ object ClientSettings extends SystemConfiguration { pluginsFromConfig } - case class DataChannel(name: String, writePermission: List[String]) + case class DataChannel(name: String, writePermission: List[String], deletePermission: List[String]) case class Plugin(name: String, url: String, dataChannels: Map[String, DataChannel]) } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginDataChannelDeleteMessageMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginDataChannelDeleteMessageMsgHdlr.scala new file mode 100755 index 0000000000..776a749566 --- /dev/null +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginDataChannelDeleteMessageMsgHdlr.scala @@ -0,0 +1,60 @@ +package org.bigbluebutton.core.apps.plugin + +import org.bigbluebutton.ClientSettings +import org.bigbluebutton.common2.msgs.PluginDataChannelDeleteMessageMsg +import org.bigbluebutton.core.db.PluginDataChannelMessageDAO +import org.bigbluebutton.core.domain.MeetingState2x +import org.bigbluebutton.core.models.{ Roles, Users2x } +import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting } + +trait PluginDataChannelDeleteMessageMsgHdlr extends HandlerHelpers { + + def handle(msg: PluginDataChannelDeleteMessageMsg, state: MeetingState2x, liveMeeting: LiveMeeting): Unit = { + val pluginsDisabled: Boolean = liveMeeting.props.meetingProp.disabledFeatures.contains("plugins") + val meetingId = liveMeeting.props.meetingProp.intId + + for { + _ <- if (!pluginsDisabled) Some(()) else None + user <- Users2x.findWithIntId(liveMeeting.users2x, msg.header.userId) + } yield { + val pluginsConfig = ClientSettings.getPluginsFromConfig(ClientSettings.clientSettingsFromFile) + + if (!pluginsConfig.contains(msg.body.pluginName)) { + println(s"Plugin '${msg.body.pluginName}' not found.") + } else if (!pluginsConfig(msg.body.pluginName).dataChannels.contains(msg.body.dataChannel)) { + println(s"Data channel '${msg.body.dataChannel}' not found in plugin '${msg.body.pluginName}'.") + } else { + val hasPermission = for { + deletePermission <- pluginsConfig(msg.body.pluginName).dataChannels(msg.body.dataChannel).deletePermission + } yield { + deletePermission.toLowerCase match { + case "all" => true + case "moderator" => user.role == Roles.MODERATOR_ROLE + case "presenter" => user.presenter + case "sender" => { + val senderUserId = PluginDataChannelMessageDAO.getMessageSender( + meetingId, + msg.body.pluginName, + msg.body.dataChannel, + msg.body.messageId + ) + senderUserId == msg.header.userId + } + case _ => false + } + } + + if (!hasPermission.contains(true)) { + println(s"No permission to delete in plugin: '${msg.body.pluginName}', data channel: '${msg.body.dataChannel}'.") + } else { + PluginDataChannelMessageDAO.delete( + meetingId, + msg.body.pluginName, + msg.body.dataChannel, + msg.body.messageId + ) + } + } + } + } +} diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/DispatchPluginDataChannelMessageMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginDataChannelDispatchMessageMsgHdlr.scala similarity index 92% rename from akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/DispatchPluginDataChannelMessageMsgHdlr.scala rename to akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginDataChannelDispatchMessageMsgHdlr.scala index db5a04c05e..7ac825961e 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/DispatchPluginDataChannelMessageMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginDataChannelDispatchMessageMsgHdlr.scala @@ -7,9 +7,9 @@ import org.bigbluebutton.core.domain.MeetingState2x import org.bigbluebutton.core.models.{ Roles, Users2x } import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting } -trait DispatchPluginDataChannelMessageMsgHdlr extends HandlerHelpers { +trait PluginDataChannelDispatchMessageMsgHdlr extends HandlerHelpers { - def handle(msg: DispatchPluginDataChannelMessageMsg, state: MeetingState2x, liveMeeting: LiveMeeting): Unit = { + def handle(msg: PluginDataChannelDispatchMessageMsg, state: MeetingState2x, liveMeeting: LiveMeeting): Unit = { val pluginsDisabled: Boolean = liveMeeting.props.meetingProp.disabledFeatures.contains("plugins") val meetingId = liveMeeting.props.meetingProp.intId diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginDataChannelResetMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginDataChannelResetMsgHdlr.scala new file mode 100755 index 0000000000..bec7301a0d --- /dev/null +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginDataChannelResetMsgHdlr.scala @@ -0,0 +1,50 @@ +package org.bigbluebutton.core.apps.plugin + +import org.bigbluebutton.ClientSettings +import org.bigbluebutton.common2.msgs.PluginDataChannelResetMsg +import org.bigbluebutton.core.db.PluginDataChannelMessageDAO +import org.bigbluebutton.core.domain.MeetingState2x +import org.bigbluebutton.core.models.{ Roles, Users2x } +import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting } + +trait PluginDataChannelResetMsgHdlr extends HandlerHelpers { + + def handle(msg: PluginDataChannelResetMsg, state: MeetingState2x, liveMeeting: LiveMeeting): Unit = { + val pluginsDisabled: Boolean = liveMeeting.props.meetingProp.disabledFeatures.contains("plugins") + val meetingId = liveMeeting.props.meetingProp.intId + + for { + _ <- if (!pluginsDisabled) Some(()) else None + user <- Users2x.findWithIntId(liveMeeting.users2x, msg.header.userId) + } yield { + val pluginsConfig = ClientSettings.getPluginsFromConfig(ClientSettings.clientSettingsFromFile) + + if (!pluginsConfig.contains(msg.body.pluginName)) { + println(s"Plugin '${msg.body.pluginName}' not found.") + } else if (!pluginsConfig(msg.body.pluginName).dataChannels.contains(msg.body.dataChannel)) { + println(s"Data channel '${msg.body.dataChannel}' not found in plugin '${msg.body.pluginName}'.") + } else { + val hasPermission = for { + deletePermission <- pluginsConfig(msg.body.pluginName).dataChannels(msg.body.dataChannel).deletePermission + } yield { + deletePermission.toLowerCase match { + case "all" => true + case "moderator" => user.role == Roles.MODERATOR_ROLE + case "presenter" => user.presenter + case _ => false + } + } + + if (!hasPermission.contains(true)) { + println(s"No permission to delete (reset) in plugin: '${msg.body.pluginName}', data channel: '${msg.body.dataChannel}'.") + } else { + PluginDataChannelMessageDAO.reset( + meetingId, + msg.body.pluginName, + msg.body.dataChannel + ) + } + } + } + } +} diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginHdlrs.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginHdlrs.scala index d706a99644..2fed179446 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginHdlrs.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/plugin/PluginHdlrs.scala @@ -4,7 +4,9 @@ import org.apache.pekko.actor.ActorContext import org.apache.pekko.event.Logging class PluginHdlrs(implicit val context: ActorContext) - extends DispatchPluginDataChannelMessageMsgHdlr { + extends PluginDataChannelDispatchMessageMsgHdlr + with PluginDataChannelDeleteMessageMsgHdlr + with PluginDataChannelResetMsgHdlr { val log = Logging(context.system, getClass) } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/PluginDataChannelMessageDAO.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/PluginDataChannelMessageDAO.scala index 26602526de..bafc8d96a7 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/PluginDataChannelMessageDAO.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/PluginDataChannelMessageDAO.scala @@ -1,9 +1,13 @@ package org.bigbluebutton.core.db import PostgresProfile.api._ +import org.bigbluebutton.core.db.DatabaseConnection.{db, logger} import spray.json.JsValue + import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.{Await, Future} import scala.util.{Failure, Success} +import scala.concurrent.duration.Duration object Permission { val allowedRoles = List("MODERATOR","VIEWER","PRESENTER") @@ -19,6 +23,7 @@ case class PluginDataChannelMessageDbModel( toRoles: Option[List[String]], toUserIds: Option[List[String]], createdAt: java.sql.Timestamp, + deletedAt: Option[java.sql.Timestamp], ) class PluginDataChannelMessageDbTableDef(tag: Tag) extends Table[PluginDataChannelMessageDbModel](tag, None, "pluginDataChannelMessage") { @@ -31,7 +36,8 @@ class PluginDataChannelMessageDbTableDef(tag: Tag) extends Table[PluginDataChann val toRoles = column[Option[List[String]]]("toRoles") val toUserIds = column[Option[List[String]]]("toUserIds") val createdAt = column[java.sql.Timestamp]("createdAt") - override def * = (meetingId, pluginName, dataChannel, payloadJson, fromUserId, toRoles, toUserIds, createdAt) <> (PluginDataChannelMessageDbModel.tupled, PluginDataChannelMessageDbModel.unapply) + val deletedAt = column[Option[java.sql.Timestamp]]("deletedAt") + override def * = (meetingId, pluginName, dataChannel, payloadJson, fromUserId, toRoles, toUserIds, createdAt, deletedAt) <> (PluginDataChannelMessageDbModel.tupled, PluginDataChannelMessageDbModel.unapply) } object PluginDataChannelMessageDAO { @@ -49,7 +55,8 @@ object PluginDataChannelMessageDAO { case filtered => Some(filtered) }, toUserIds = if(toUserIds.isEmpty) None else Some(toUserIds), - createdAt = new java.sql.Timestamp(System.currentTimeMillis()) + createdAt = new java.sql.Timestamp(System.currentTimeMillis()), + deletedAt = None ) ) ).onComplete { @@ -57,4 +64,51 @@ object PluginDataChannelMessageDAO { case Failure(e) => DatabaseConnection.logger.debug(s"Error inserting PluginDataChannelMessage: $e") } } + + def reset(meetingId: String, pluginName: String, dataChannel: String) = { + DatabaseConnection.db.run( + TableQuery[PluginDataChannelMessageDbTableDef] + .filter(_.meetingId === meetingId) + .filter(_.pluginName === pluginName) + .filter(_.dataChannel === dataChannel) + .map(u => (u.deletedAt)) + .update(Some(new java.sql.Timestamp(System.currentTimeMillis()))) + ).onComplete { + case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated deleted=now() on pluginDataChannelMessage table!") + case Failure(e) => DatabaseConnection.logger.error(s"Error updating deleted=now() pluginDataChannelMessage: $e") + } + } + + def getMessageSender(meetingId: String, pluginName: String, dataChannel: String, messageId: String): String = { + val query = sql"""SELECT "fromUserId" + FROM "pluginDataChannelMessage" + WHERE "deletedAt" is null + AND "meetingId" = ${meetingId} + AND "pluginName" = ${pluginName} + AND "dataChannel" = ${dataChannel} + AND "messageId" = ${messageId}""".as[String].headOption + + Await.result(DatabaseConnection.db.run(query), Duration.Inf) match { + case Some(userId) => userId + case None => { + logger.debug("Message {} not found in database (maybe it was deleted).", messageId) + "" + } + } + } + + def delete(meetingId: String, pluginName: String, dataChannel: String, messageId: String) = { + DatabaseConnection.db.run( + sqlu"""UPDATE "pluginDataChannelMessage" SET + "deletedAt" = current_timestamp + WHERE "meetingId" = ${meetingId} + AND "pluginName" = ${pluginName} + AND "dataChannel" = ${dataChannel} + AND "messageId" = ${messageId}""" + ).onComplete { + case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated deleted=now() on pluginDataChannelMessage table!") + case Failure(e) => DatabaseConnection.logger.debug(s"Error updating deleted=now() pluginDataChannelMessage: $e") + } + } + } \ No newline at end of file diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/pubsub/senders/ReceivedJsonMsgHandlerActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/pubsub/senders/ReceivedJsonMsgHandlerActor.scala index d122c73886..d6e93fba7d 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/pubsub/senders/ReceivedJsonMsgHandlerActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/pubsub/senders/ReceivedJsonMsgHandlerActor.scala @@ -416,8 +416,14 @@ class ReceivedJsonMsgHandlerActor( routeGenericMsg[CreateGroupChatReqMsg](envelope, jsonNode) //Plugin - case DispatchPluginDataChannelMessageMsg.NAME => - routeGenericMsg[DispatchPluginDataChannelMessageMsg](envelope, jsonNode) + case PluginDataChannelDispatchMessageMsg.NAME => + routeGenericMsg[PluginDataChannelDispatchMessageMsg](envelope, jsonNode) + + case PluginDataChannelDeleteMessageMsg.NAME => + routeGenericMsg[PluginDataChannelDeleteMessageMsg](envelope, jsonNode) + + case PluginDataChannelResetMsg.NAME => + routeGenericMsg[PluginDataChannelResetMsg](envelope, jsonNode) // ExternalVideo case StartExternalVideoPubMsg.NAME => diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala index 20769242c8..46e9742747 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala @@ -594,7 +594,9 @@ class MeetingActor( updateUserLastActivity(m.body.msg.sender.id) // Plugin - case m: DispatchPluginDataChannelMessageMsg => pluginHdlrs.handle(m, state, liveMeeting) + case m: PluginDataChannelDispatchMessageMsg => pluginHdlrs.handle(m, state, liveMeeting) + case m: PluginDataChannelDeleteMessageMsg => pluginHdlrs.handle(m, state, liveMeeting) + case m: PluginDataChannelResetMsg => pluginHdlrs.handle(m, state, liveMeeting) // Webcams case m: UserBroadcastCamStartMsg => webcamApp2x.handle(m, liveMeeting, msgBus) diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/PluginMsgs.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/PluginMsgs.scala index e263923105..dc80f351b7 100755 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/PluginMsgs.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/PluginMsgs.scala @@ -5,12 +5,28 @@ package org.bigbluebutton.common2.msgs /** * Sent from graphql-actions to bbb-akka */ -object DispatchPluginDataChannelMessageMsg { val NAME = "DispatchPluginDataChannelMessageMsg" } -case class DispatchPluginDataChannelMessageMsg(header: BbbClientMsgHeader, body: DispatchPluginDataChannelMessageMsgBody) extends StandardMsg -case class DispatchPluginDataChannelMessageMsgBody( +object PluginDataChannelDispatchMessageMsg { val NAME = "PluginDataChannelDispatchMessageMsg" } +case class PluginDataChannelDispatchMessageMsg(header: BbbClientMsgHeader, body: PluginDataChannelDispatchMessageMsgBody) extends StandardMsg +case class PluginDataChannelDispatchMessageMsgBody( pluginName: String, dataChannel: String, payloadJson: String, toRoles: List[String], toUserIds: List[String], ) + +object PluginDataChannelDeleteMessageMsg { val NAME = "PluginDataChannelDeleteMessageMsg" } +case class PluginDataChannelDeleteMessageMsg(header: BbbClientMsgHeader, body: PluginDataChannelDeleteMessageMsgBody) extends StandardMsg +case class PluginDataChannelDeleteMessageMsgBody( + pluginName: String, + dataChannel: String, + messageId: String + ) + + +object PluginDataChannelResetMsg { val NAME = "PluginDataChannelResetMsg" } +case class PluginDataChannelResetMsg(header: BbbClientMsgHeader, body: PluginDataChannelResetMsgBody) extends StandardMsg +case class PluginDataChannelResetMsgBody( + pluginName: String, + dataChannel: String + ) diff --git a/bbb-graphql-actions/src/actions/pluginDataChannelDeleteMessage.ts b/bbb-graphql-actions/src/actions/pluginDataChannelDeleteMessage.ts new file mode 100644 index 0000000000..892d0c0706 --- /dev/null +++ b/bbb-graphql-actions/src/actions/pluginDataChannelDeleteMessage.ts @@ -0,0 +1,25 @@ +import { RedisMessage } from '../types'; +import { ValidationError } from '../types/ValidationError'; + +export default function buildRedisMessage(sessionVariables: Record, input: Record): RedisMessage { + const eventName = `PluginDataChannelDeleteMessageMsg`; + + const routing = { + meetingId: sessionVariables['x-hasura-meetingid'] as String, + userId: sessionVariables['x-hasura-userid'] as String + }; + + const header = { + name: eventName, + meetingId: routing.meetingId, + userId: routing.userId + }; + + const body = { + pluginName: input.pluginName, + dataChannel: input.dataChannel, + messageId: input.messageId + }; + + return { eventName, routing, header, body }; +} diff --git a/bbb-graphql-actions/src/actions/dispatchPluginDataChannelMessageMsg.ts b/bbb-graphql-actions/src/actions/pluginDataChannelDispatchMessage.ts similarity index 93% rename from bbb-graphql-actions/src/actions/dispatchPluginDataChannelMessageMsg.ts rename to bbb-graphql-actions/src/actions/pluginDataChannelDispatchMessage.ts index 2008398483..0a380d028c 100644 --- a/bbb-graphql-actions/src/actions/dispatchPluginDataChannelMessageMsg.ts +++ b/bbb-graphql-actions/src/actions/pluginDataChannelDispatchMessage.ts @@ -2,7 +2,7 @@ import { RedisMessage } from '../types'; import { ValidationError } from '../types/ValidationError'; export default function buildRedisMessage(sessionVariables: Record, input: Record): RedisMessage { - const eventName = `DispatchPluginDataChannelMessageMsg`; + const eventName = `PluginDataChannelDispatchMessageMsg`; const routing = { meetingId: sessionVariables['x-hasura-meetingid'] as String, diff --git a/bbb-graphql-actions/src/actions/pluginDataChannelReset.ts b/bbb-graphql-actions/src/actions/pluginDataChannelReset.ts new file mode 100644 index 0000000000..11045e9f06 --- /dev/null +++ b/bbb-graphql-actions/src/actions/pluginDataChannelReset.ts @@ -0,0 +1,24 @@ +import { RedisMessage } from '../types'; +import { ValidationError } from '../types/ValidationError'; + +export default function buildRedisMessage(sessionVariables: Record, input: Record): RedisMessage { + const eventName = `PluginDataChannelResetMsg`; + + const routing = { + meetingId: sessionVariables['x-hasura-meetingid'] as String, + userId: sessionVariables['x-hasura-userid'] as String + }; + + const header = { + name: eventName, + meetingId: routing.meetingId, + userId: routing.userId + }; + + const body = { + pluginName: input.pluginName, + dataChannel: input.dataChannel + }; + + return { eventName, routing, header, body }; +} diff --git a/bbb-graphql-client-test/src/PluginDataChannel.js b/bbb-graphql-client-test/src/PluginDataChannel.js index 18d3f3b460..711f75a1d5 100644 --- a/bbb-graphql-client-test/src/PluginDataChannel.js +++ b/bbb-graphql-client-test/src/PluginDataChannel.js @@ -5,9 +5,9 @@ import {useState} from "react"; export default function PluginDataChannel({userId}) { const [textAreaValue, setTextAreaValue] = useState(``); - const [dispatchPluginDataChannelMessage] = useMutation(gql` - mutation DispatchPluginDataChannelMessageMsg($pluginName: String!, $dataChannel: String!, $payloadJson: String!, $toRoles: [String]!,$toUserIds: [String]!) { - dispatchPluginDataChannelMessageMsg( + const [pluginDataChannelDispatchMessage] = useMutation(gql` + mutation PluginDataChannelDispatchMessage($pluginName: String!, $dataChannel: String!, $payloadJson: String!, $toRoles: [String]!,$toUserIds: [String]!) { + pluginDataChannelDispatchMessage( pluginName: $pluginName, dataChannel: $dataChannel, payloadJson: $payloadJson, @@ -16,12 +16,12 @@ export default function PluginDataChannel({userId}) { ) } `); - const handleDispatchPluginDataChannelMessage = (roles, userIds) => { + const handlePluginDataChannelDispatchMessage = (roles, userIds) => { if (textAreaValue.trim() !== '') { - dispatchPluginDataChannelMessage({ + pluginDataChannelDispatchMessage({ variables: { - pluginName: 'SamplePresentationToolbarPlugin', - dataChannel: 'public-channel', + pluginName: 'SelectRandomUserPlugin', + dataChannel: 'pickRandomUser', payloadJson: textAreaValue, toRoles: roles, toUserIds: userIds, @@ -30,6 +30,42 @@ export default function PluginDataChannel({userId}) { } }; + const [pluginDataChannelReset] = useMutation(gql` + mutation PluginDataChannelReset($pluginName: String!, $dataChannel: String!) { + pluginDataChannelReset( + pluginName: $pluginName, + dataChannel: $dataChannel + ) + } + `); + const handlePluginDataChannelReset = () => { + pluginDataChannelReset({ + variables: { + pluginName: 'SelectRandomUserPlugin', + dataChannel: 'pickRandomUser' + }, + }); + }; + + const [pluginDataChannelDeleteMessage] = useMutation(gql` + mutation PluginDataChannelDeleteMessage($pluginName: String!, $dataChannel: String!, $messageId: String!) { + pluginDataChannelDeleteMessage( + pluginName: $pluginName, + dataChannel: $dataChannel, + messageId: $messageId + ) + } + `); + const handlePluginDataChannelDeleteMessage = (messageId) => { + pluginDataChannelDeleteMessage({ + variables: { + pluginName: 'SelectRandomUserPlugin', + dataChannel: 'pickRandomUser', + messageId: messageId + }, + }); + }; + const { loading, error, data } = usePatchedSubscription( gql`subscription { pluginDataChannelMessage(order_by: {createdAt: asc}) { @@ -74,6 +110,9 @@ export default function PluginDataChannel({userId}) { + ); })} @@ -86,12 +125,13 @@ export default function PluginDataChannel({userId}) { value={textAreaValue} onChange={(e) => setTextAreaValue(e.target.value)} > - - - - - - + + + + + + + diff --git a/bbb-graphql-server/bbb_schema.sql b/bbb-graphql-server/bbb_schema.sql index 6d8360a18e..0450e87f8f 100644 --- a/bbb-graphql-server/bbb_schema.sql +++ b/bbb-graphql-server/bbb_schema.sql @@ -1501,7 +1501,7 @@ create table "sharedNotes_rev" ( --create view "v_sharedNotes_rev" as select * from "sharedNotes_rev"; create view "v_sharedNotes_diff" as -select "meetingId", "sharedNotesExtId", "userId", "start", "end", "diff" +select "meetingId", "sharedNotesExtId", "userId", "start", "end", "diff", "rev" from "sharedNotes_rev" where "diff" is not null; @@ -1585,12 +1585,13 @@ CREATE TABLE "pluginDataChannelMessage" ( "fromUserId" varchar(50) REFERENCES "user"("userId") ON DELETE CASCADE, "toRoles" varchar[], --MODERATOR, VIEWER, PRESENTER "toUserIds" varchar[], - "createdAt" timestamp with time ZONE DEFAULT current_timestamp, + "createdAt" timestamp with time zone DEFAULT current_timestamp, + "deletedAt" timestamp with time zone, CONSTRAINT "pluginDataChannel_pkey" PRIMARY KEY ("meetingId","pluginName","dataChannel","messageId") ); -create index "idx_pluginDataChannelMessage_dataChannel" on "pluginDataChannelMessage"("meetingId", "pluginName", "dataChannel", "toRoles", "toUserIds", "createdAt"); -create index "idx_pluginDataChannelMessage_roles" on "pluginDataChannelMessage"("meetingId", "toRoles", "toUserIds", "createdAt"); +create index "idx_pluginDataChannelMessage_dataChannel" on "pluginDataChannelMessage"("meetingId", "pluginName", "dataChannel", "toRoles", "toUserIds", "createdAt") where "deletedAt" is null; +create index "idx_pluginDataChannelMessage_roles" on "pluginDataChannelMessage"("meetingId", "toRoles", "toUserIds", "createdAt") where "deletedAt" is null; CREATE OR REPLACE VIEW "v_pluginDataChannelMessage" AS SELECT u."meetingId", u."userId", m."pluginName", m."dataChannel", m."messageId", m."payloadJson", m."fromUserId", m."toRoles", m."createdAt" @@ -1601,6 +1602,7 @@ JOIN "pluginDataChannelMessage" m ON m."meetingId" = u."meetingId" OR u."role" = ANY(m."toRoles") OR (u."presenter" AND 'PRESENTER' = ANY(m."toRoles")) ) +WHERE "deletedAt" is null ORDER BY m."createdAt"; ------------------------ diff --git a/bbb-graphql-server/metadata/actions.graphql b/bbb-graphql-server/metadata/actions.graphql index ed7fddf9fb..cb2eae2798 100644 --- a/bbb-graphql-server/metadata/actions.graphql +++ b/bbb-graphql-server/metadata/actions.graphql @@ -100,16 +100,6 @@ type Mutation { ): Boolean } -type Mutation { - dispatchPluginDataChannelMessageMsg( - pluginName: String! - dataChannel: String! - payloadJson: String! - toRoles: [String]! - toUserIds: [String]! - ): Boolean -} - type Mutation { externalVideoStart( externalVideoUrl: String! @@ -210,6 +200,31 @@ type Mutation { ): Boolean } +type Mutation { + pluginDataChannelDeleteMessage( + pluginName: String! + dataChannel: String! + messageId: String! + ): Boolean +} + +type Mutation { + pluginDataChannelDispatchMessage( + pluginName: String! + dataChannel: String! + payloadJson: String! + toRoles: [String]! + toUserIds: [String]! + ): Boolean +} + +type Mutation { + pluginDataChannelReset( + pluginName: String! + dataChannel: String! + ): Boolean +} + type Mutation { pollCancel: Boolean } diff --git a/bbb-graphql-server/metadata/actions.yaml b/bbb-graphql-server/metadata/actions.yaml index ddfbbd7238..3a87de56bd 100644 --- a/bbb-graphql-server/metadata/actions.yaml +++ b/bbb-graphql-server/metadata/actions.yaml @@ -95,12 +95,6 @@ actions: handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}' permissions: - role: bbb_client - - name: dispatchPluginDataChannelMessageMsg - definition: - kind: synchronous - handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}' - permissions: - - role: bbb_client - name: externalVideoStart definition: kind: synchronous @@ -185,6 +179,24 @@ actions: handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}' permissions: - role: bbb_client + - name: pluginDataChannelDeleteMessage + definition: + kind: synchronous + handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}' + permissions: + - role: bbb_client + - name: pluginDataChannelDispatchMessage + definition: + kind: synchronous + handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}' + permissions: + - role: bbb_client + - name: pluginDataChannelReset + definition: + kind: synchronous + handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}' + permissions: + - role: bbb_client - name: pollCancel definition: kind: synchronous From 2ffd9ce7e9d11f6764c3784f90a213e1fa7f1096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Fri, 19 Jan 2024 10:44:27 -0300 Subject: [PATCH 0177/1039] migrate startWatchingExternalVideo action --- .../api/external-videos/server/methods.js | 2 - .../methods/startWatchingExternalVideo.js | 26 ------------ .../modal/component.tsx | 24 ++++++++++- .../modal/service.ts | 17 -------- .../external-video-player/mutations.tsx | 11 +++++ .../external-video-player/service.js | 19 --------- .../smart-video-share/component.jsx | 12 +++--- .../smart-video-share/container.jsx | 40 +++++++++++++++---- 8 files changed, 73 insertions(+), 78 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/external-videos/server/methods/startWatchingExternalVideo.js create mode 100644 bigbluebutton-html5/imports/ui/components/external-video-player/mutations.tsx diff --git a/bigbluebutton-html5/imports/api/external-videos/server/methods.js b/bigbluebutton-html5/imports/api/external-videos/server/methods.js index 10c056a2a9..be2877d4dd 100644 --- a/bigbluebutton-html5/imports/api/external-videos/server/methods.js +++ b/bigbluebutton-html5/imports/api/external-videos/server/methods.js @@ -1,10 +1,8 @@ import { Meteor } from 'meteor/meteor'; -import startWatchingExternalVideo from './methods/startWatchingExternalVideo'; import stopWatchingExternalVideo from './methods/stopWatchingExternalVideo'; import emitExternalVideoEvent from './methods/emitExternalVideoEvent'; Meteor.methods({ - startWatchingExternalVideo, stopWatchingExternalVideo, emitExternalVideoEvent, }); diff --git a/bigbluebutton-html5/imports/api/external-videos/server/methods/startWatchingExternalVideo.js b/bigbluebutton-html5/imports/api/external-videos/server/methods/startWatchingExternalVideo.js deleted file mode 100644 index cd579dabc5..0000000000 --- a/bigbluebutton-html5/imports/api/external-videos/server/methods/startWatchingExternalVideo.js +++ /dev/null @@ -1,26 +0,0 @@ -import { check } from 'meteor/check'; -import Logger from '/imports/startup/server/logger'; -import RedisPubSub from '/imports/startup/server/redis'; -import { extractCredentials } from '/imports/api/common/server/helpers'; - -export default function startWatchingExternalVideo(externalVideoUrl) { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'StartExternalVideoPubMsg'; - - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(externalVideoUrl, String); - - const payload = { externalVideoUrl }; - - Logger.info(`User ${requesterUserId} sharing an external video ${externalVideoUrl} for meeting ${meetingId}`); - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (error) { - Logger.error(`Error on sharing an external video for meeting ${meetingId}: ${error}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/modal/component.tsx b/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/modal/component.tsx index 27f199d2de..202d760b5f 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/modal/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/modal/component.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { useMutation } from '@apollo/client'; import Styled from './styles'; import SettingsSingleton from '/imports/ui/services/settings'; -import { startWatching, isUrlValid } from './service'; +import { isUrlValid } from './service'; +import { EXTERNAL_VIDEO_START } from '../../mutations'; const intlMessages = defineMessages({ start: { @@ -35,6 +37,9 @@ const intlMessages = defineMessages({ }, }); +const YOUTUBE_SHORTS_REGEX = new RegExp(/^(?:https?:\/\/)?(?:www\.)?(youtube\.com\/shorts)\/.+$/); +const PANOPTO_MATCH_URL = /https?:\/\/([^/]+\/Panopto)(\/Pages\/Viewer\.aspx\?id=)([-a-zA-Z0-9]+)/; + interface ExternalVideoPlayerModalProps { onRequestClose: () => void, priority: string, @@ -52,6 +57,23 @@ const ExternalVideoPlayerModal: React.FC = ({ // @ts-ignore - settings is a js singleton const { animations } = SettingsSingleton.application; const [videoUrl, setVideoUrl] = React.useState(''); + const [startExternalVideo] = useMutation(EXTERNAL_VIDEO_START); + + const startWatching = (url: string) => { + let externalVideoUrl = url; + + if (YOUTUBE_SHORTS_REGEX.test(url)) { + const shortsUrl = url.replace('shorts/', 'watch?v='); + externalVideoUrl = shortsUrl; + } else if (PANOPTO_MATCH_URL.test(url)) { + const m = url.match(PANOPTO_MATCH_URL); + if (m && m.length >= 4) { + externalVideoUrl = `https://${m[1]}/Podcast/Social/${m[3]}.mp4`; + } + } + + startExternalVideo({ variables: { externalVideoUrl } }); + }; const valid = isUrlValid(videoUrl); diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/modal/service.ts b/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/modal/service.ts index ffc93d633e..a980a5b3b8 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/modal/service.ts +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/modal/service.ts @@ -8,22 +8,6 @@ export const stopWatching = () => { makeCall('stopWatchingExternalVideo'); }; -export const startWatching = (url: string) => { - let externalVideoUrl = url; - - if (YOUTUBE_SHORTS_REGEX.test(url)) { - const shortsUrl = url.replace('shorts/', 'watch?v='); - externalVideoUrl = shortsUrl; - } else if (PANOPTO_MATCH_URL.test(url)) { - const m = url.match(PANOPTO_MATCH_URL); - if (m && m.length >= 4) { - externalVideoUrl = `https://${m[1]}/Podcast/Social/${m[3]}.mp4`; - } - } - - makeCall('startWatchingExternalVideo', externalVideoUrl); -}; - export const isUrlValid = (url: string) => { if (YOUTUBE_SHORTS_REGEX.test(url)) { const shortsUrl = url.replace('shorts/', 'watch?v='); @@ -36,5 +20,4 @@ export const isUrlValid = (url: string) => { export default { stopWatching, - startWatching, }; diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/mutations.tsx b/bigbluebutton-html5/imports/ui/components/external-video-player/mutations.tsx new file mode 100644 index 0000000000..cc143f249a --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/mutations.tsx @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const EXTERNAL_VIDEO_START = gql` + mutation ExternalVideoStart($externalVideoUrl: String!) { + externalVideoStart( + externalVideoUrl: $externalVideoUrl + ) + } +`; + +export default { EXTERNAL_VIDEO_START }; diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/service.js b/bigbluebutton-html5/imports/ui/components/external-video-player/service.js index 4976f37529..5fc1064fec 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/service.js +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/service.js @@ -2,8 +2,6 @@ import Auth from '/imports/ui/services/auth'; import { getStreamer } from '/imports/api/external-videos'; import { makeCall } from '/imports/ui/services/api'; -import NotesService from '/imports/ui/components/notes/service'; - import ReactPlayer from 'react-player'; import Panopto from './custom-players/panopto'; @@ -20,22 +18,6 @@ const isUrlValid = (url) => { return /^https.*$/.test(url) && (ReactPlayer.canPlay(url) || Panopto.canPlay(url)); }; -const startWatching = (url) => { - let externalVideoUrl = url; - - if (YOUTUBE_SHORTS_REGEX.test(url)) { - const shortsUrl = url.replace('shorts/', 'watch?v='); - externalVideoUrl = shortsUrl; - } else if (Panopto.canPlay(url)) { - externalVideoUrl = Panopto.getSocialUrl(url); - } - - // Close Shared Notes if open. - NotesService.pinSharedNotes(false); - - makeCall('startWatchingExternalVideo', externalVideoUrl); -}; - const stopWatching = () => { makeCall('stopWatchingExternalVideo'); }; @@ -87,7 +69,6 @@ export { onMessage, removeAllListeners, isUrlValid, - startWatching, stopWatching, getPlayingState, }; diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/smart-video-share/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/smart-video-share/component.jsx index 8d4f470050..6b2f06e645 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/smart-video-share/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/smart-video-share/component.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages } from 'react-intl'; import { safeMatch } from '/imports/utils/string-utils'; -import { isUrlValid, startWatching } from '/imports/ui/components/external-video-player/service'; +import { isUrlValid } from '/imports/ui/components/external-video-player/service'; import BBBMenu from '/imports/ui/components/common/menu/component'; import Styled from './styles'; @@ -12,10 +12,10 @@ const intlMessages = defineMessages({ }, }); -const createAction = (url) => { - const hasHttps = url?.startsWith("https://"); +const createAction = (url, startWatching) => { + const hasHttps = url?.startsWith('https://'); const finalUrl = hasHttps ? url : `https://${url}`; - const label = hasHttps ? url?.replace("https://", "") : url; + const label = hasHttps ? url?.replace('https://', '') : url; if (isUrlValid(finalUrl)) { return { @@ -27,7 +27,7 @@ const createAction = (url) => { export const SmartMediaShare = (props) => { const { - currentSlide, intl, isMobile, isRTL, + currentSlide, intl, isMobile, isRTL, startWatching, } = props; const linkPatt = /(https?:\/\/.*?)(?=\s|$)/g; const externalLinks = safeMatch(linkPatt, currentSlide?.content?.replace(/[\r\n]/g, ' '), false); @@ -36,7 +36,7 @@ export const SmartMediaShare = (props) => { const actions = []; externalLinks?.forEach((l) => { - const action = createAction(l); + const action = createAction(l, startWatching); if (action) actions.push(action); }); diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/smart-video-share/container.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/smart-video-share/container.jsx index 0a28ce00fd..83913a47fb 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/smart-video-share/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/smart-video-share/container.jsx @@ -1,16 +1,42 @@ import React from 'react'; import { withTracker } from 'meteor/react-meteor-data'; +import { useMutation } from '@apollo/client'; import { SmartMediaShare } from './component'; - +import NotesService from '/imports/ui/components/notes/service'; +import Panopto from '../../../external-video-player/custom-players/panopto'; import { layoutSelect } from '/imports/ui/components/layout/context'; import { isMobile } from '/imports/ui/components/layout/utils'; +import { EXTERNAL_VIDEO_START } from '../../../external-video-player/mutations'; -const SmartMediaShareContainer = (props) => ( - -); +const YOUTUBE_SHORTS_REGEX = new RegExp(/^(?:https?:\/\/)?(?:www\.)?(youtube\.com\/shorts)\/.+$/); + +const SmartMediaShareContainer = (props) => { + const [startExternalVideo] = useMutation(EXTERNAL_VIDEO_START); + + const startWatching = (url) => { + let externalVideoUrl = url; + + if (YOUTUBE_SHORTS_REGEX.test(url)) { + const shortsUrl = url.replace('shorts/', 'watch?v='); + externalVideoUrl = shortsUrl; + } else if (Panopto.canPlay(url)) { + externalVideoUrl = Panopto.getSocialUrl(url); + } + + // Close Shared Notes if open. + NotesService.pinSharedNotes(false); + + startExternalVideo({ variables: { externalVideoUrl } }); + }; + + return ( + + ); +}; export default withTracker(() => { const isRTL = layoutSelect((i) => i.isRTL); From 607bfbdc324ea869adb9fefa14fb703bacfa6dc6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jan 2024 09:12:12 -0500 Subject: [PATCH 0178/1039] build(deps): bump follow-redirects in /bbb-learning-dashboard (#19403) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.1 to 1.15.4. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.1...v1.15.4) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- bbb-learning-dashboard/package-lock.json | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bbb-learning-dashboard/package-lock.json b/bbb-learning-dashboard/package-lock.json index 036157a229..b882badc95 100644 --- a/bbb-learning-dashboard/package-lock.json +++ b/bbb-learning-dashboard/package-lock.json @@ -7225,14 +7225,15 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.1", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], - "license": "MIT", "engines": { "node": ">=4.0" }, @@ -20080,7 +20081,9 @@ "version": "3.2.5" }, "follow-redirects": { - "version": "1.15.1" + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==" }, "fork-ts-checker-webpack-plugin": { "version": "6.5.2", @@ -25130,4 +25133,4 @@ "version": "0.1.0" } } -} \ No newline at end of file +} From ad1324f5f7531c4a05f2a40cc8ef004da556b0a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jan 2024 09:14:20 -0500 Subject: [PATCH 0179/1039] build(deps): bump follow-redirects in /bbb-graphql-actions (#19416) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.3 to 1.15.4. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.4) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- bbb-graphql-actions/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bbb-graphql-actions/package-lock.json b/bbb-graphql-actions/package-lock.json index bad7d4877e..237413722f 100644 --- a/bbb-graphql-actions/package-lock.json +++ b/bbb-graphql-actions/package-lock.json @@ -615,9 +615,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "funding": [ { "type": "individual", From b142603d44806f8b32fec3c701bb4d37eebbe8dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jan 2024 09:14:34 -0500 Subject: [PATCH 0180/1039] build(deps): bump follow-redirects in /bigbluebutton-tests/puppeteer (#19415) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.3 to 1.15.4. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.4) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../puppeteer/package-lock.json | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/bigbluebutton-tests/puppeteer/package-lock.json b/bigbluebutton-tests/puppeteer/package-lock.json index 5fefbac390..a0e01de301 100644 --- a/bigbluebutton-tests/puppeteer/package-lock.json +++ b/bigbluebutton-tests/puppeteer/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "puppeteer", "dependencies": { "axios": "^1.6.0", "babel-jest": "^27.5.1", @@ -2094,9 +2093,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "funding": [ { "type": "individual", @@ -2112,11 +2111,6 @@ } } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "node_modules/form-data": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", @@ -4766,6 +4760,11 @@ "node": ">= 6" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -7067,9 +7066,9 @@ } }, "follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==" + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==" }, "form-data": { "version": "3.0.1", @@ -7150,11 +7149,6 @@ "path-is-absolute": "^1.0.0" } }, - "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -8999,6 +8993,11 @@ "sisteransi": "^1.0.5" } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", From 4e85f63851fa52521595bccf27f2994d10afce42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jan 2024 09:14:58 -0500 Subject: [PATCH 0181/1039] build(deps): bump follow-redirects from 1.15.3 to 1.15.4 in /docs (#19414) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.3 to 1.15.4. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.4) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index ab111d0137..6244b2efaa 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -6450,9 +6450,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "funding": [ { "type": "individual", From 8ff29184cf21cacfc14cabdcbd9d0665959719ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jan 2024 09:20:09 -0500 Subject: [PATCH 0182/1039] build(deps): bump follow-redirects in /bbb-graphql-client-test (#19411) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.2 to 1.15.4. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.2...v1.15.4) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- bbb-graphql-client-test/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bbb-graphql-client-test/package-lock.json b/bbb-graphql-client-test/package-lock.json index 9676ebcd72..719b009899 100644 --- a/bbb-graphql-client-test/package-lock.json +++ b/bbb-graphql-client-test/package-lock.json @@ -8087,9 +8087,9 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" }, "node_modules/follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "funding": [ { "type": "individual", @@ -23004,9 +23004,9 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" }, "follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==" }, "for-each": { "version": "0.3.3", From 43c4e960f284d7e87f92b0299c7e4b5b44b5a5bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jan 2024 09:20:30 -0500 Subject: [PATCH 0183/1039] build(deps): bump follow-redirects in /bigbluebutton-tests/playwright (#19405) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.2 to 1.15.4. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.2...v1.15.4) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- bigbluebutton-tests/playwright/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bigbluebutton-tests/playwright/package-lock.json b/bigbluebutton-tests/playwright/package-lock.json index 4be69edbda..ec34bf1916 100644 --- a/bigbluebutton-tests/playwright/package-lock.json +++ b/bigbluebutton-tests/playwright/package-lock.json @@ -242,9 +242,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "funding": [ { "type": "individual", @@ -1010,9 +1010,9 @@ } }, "follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==" }, "for-each": { "version": "0.3.3", From 600fe50b731dc3c2d84b0fde11ee1e1458814735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Fri, 19 Jan 2024 11:46:11 -0300 Subject: [PATCH 0184/1039] migrate emitExternalVideoEvent action --- .../api/external-videos/server/methods.js | 2 - .../server/methods/emitExternalVideoEvent.js | 40 ----------------- .../component.tsx | 45 ++++++++++++++++++- .../external-video-player-graphql/service.ts | 36 --------------- .../external-video-player/mutations.tsx | 18 +++++++- .../external-video-player/service.js | 26 ----------- 6 files changed, 60 insertions(+), 107 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/external-videos/server/methods/emitExternalVideoEvent.js delete mode 100644 bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/service.ts diff --git a/bigbluebutton-html5/imports/api/external-videos/server/methods.js b/bigbluebutton-html5/imports/api/external-videos/server/methods.js index be2877d4dd..710daa890b 100644 --- a/bigbluebutton-html5/imports/api/external-videos/server/methods.js +++ b/bigbluebutton-html5/imports/api/external-videos/server/methods.js @@ -1,8 +1,6 @@ import { Meteor } from 'meteor/meteor'; import stopWatchingExternalVideo from './methods/stopWatchingExternalVideo'; -import emitExternalVideoEvent from './methods/emitExternalVideoEvent'; Meteor.methods({ stopWatchingExternalVideo, - emitExternalVideoEvent, }); diff --git a/bigbluebutton-html5/imports/api/external-videos/server/methods/emitExternalVideoEvent.js b/bigbluebutton-html5/imports/api/external-videos/server/methods/emitExternalVideoEvent.js deleted file mode 100644 index 3169c96797..0000000000 --- a/bigbluebutton-html5/imports/api/external-videos/server/methods/emitExternalVideoEvent.js +++ /dev/null @@ -1,40 +0,0 @@ -import { check } from 'meteor/check'; -import Logger from '/imports/startup/server/logger'; -import RedisPubSub from '/imports/startup/server/redis'; -import { extractCredentials } from '/imports/api/common/server/helpers'; - -export default function emitExternalVideoEvent(options) { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'UpdateExternalVideoPubMsg'; - - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - - const { status, playerStatus } = options; - - check(status, String); - check(playerStatus, { - rate: Match.Maybe(Number), - time: Match.Maybe(Number), - state: Match.Maybe(Number), - }); - - const state = playerStatus.state || 0; - - const payload = { - status, - rate: playerStatus.rate || 0, - time: playerStatus.time || 0, - state, - }; - - Logger.debug(`User id=${requesterUserId} sending ${EVENT_NAME} event:${state} for meeting ${meetingId}`); - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method emitExternalVideoEvent ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/component.tsx index b34b391d4e..08bec6e428 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/component.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef } from 'react'; import ReactPlayer from 'react-player'; import { defineMessages, useIntl } from 'react-intl'; import audioManager from '/imports/ui/services/audio-manager'; -import { useReactiveVar } from '@apollo/client'; +import { useReactiveVar, useMutation } from '@apollo/client'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; import { OnProgressProps } from 'react-player/base'; @@ -24,8 +24,8 @@ import { uniqueId } from '/imports/utils/string-utils'; import useTimeSync from '/imports/ui/core/local-states/useTimeSync'; import ExternalVideoPlayerToolbar from './toolbar/component'; import deviceInfo from '/imports/utils/deviceInfo'; -import { sendMessage } from './service'; import { ACTIONS } from '../../layout/enums'; +import { EXTERNAL_VIDEO_UPDATE } from '../mutations'; import PeerTube from '../custom-players/peertube'; import { ArcPlayer } from '../custom-players/arc-player'; @@ -160,6 +160,47 @@ const ExternalVideoPlayer: React.FC = ({ const playerRef = useRef(); const playerParentRef = useRef(null); const timeoutRef = useRef>(); + + const [updateExternalVideo] = useMutation(EXTERNAL_VIDEO_UPDATE); + + let lastMessage: { + event: string; + rate: number; + time: number; + state?: string; + } = { event: '', rate: 0, time: 0 }; + + const sendMessage = (event: string, data: { rate: number; time: number; state?: string}) => { + // don't re-send repeated update messages + if ( + lastMessage.event === event + && lastMessage.time === data.time + ) { + return; + } + + // don't register to redis a viewer joined message + if (event === 'viewerJoined') { + return; + } + + lastMessage = { ...data, event }; + + // Use an integer for playing state + // 0: stopped 1: playing + // We might use more states in the future + const state = data.state ? 1 : 0; + + updateExternalVideo({ + variables: { + status: event, + rate: data?.rate, + time: data?.time, + state, + }, + }); + }; + useEffect(() => { timeoutRef.current = setTimeout(() => { setAutoPlayBlocked(true); diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/service.ts b/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/service.ts deleted file mode 100644 index a7a721b9be..0000000000 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { makeCall } from '/imports/ui/services/api'; - -let lastMessage: { - event: string; - rate: number; - time: number; - state?: string; -} = { event: '', rate: 0, time: 0 }; - -export const sendMessage = (event: string, data: { rate: number; time: number; state?: string}) => { - // don't re-send repeated update messages - if ( - lastMessage.event === event - && lastMessage.time === data.time - ) { - return; - } - - // don't register to redis a viewer joined message - if (event === 'viewerJoined') { - return; - } - - lastMessage = { ...data, event }; - - // Use an integer for playing state - // 0: stopped 1: playing - // We might use more states in the future - const state = data.state ? 1 : 0; - - makeCall('emitExternalVideoEvent', { status: event, playerStatus: { ...data, state } }); -}; - -export default { - sendMessage, -}; diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/mutations.tsx b/bigbluebutton-html5/imports/ui/components/external-video-player/mutations.tsx index cc143f249a..78d44fbc59 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/mutations.tsx +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/mutations.tsx @@ -8,4 +8,20 @@ export const EXTERNAL_VIDEO_START = gql` } `; -export default { EXTERNAL_VIDEO_START }; +export const EXTERNAL_VIDEO_UPDATE = gql` + mutation ExternalVideoUpdate( + $status: String! + $rate: Float!, + $time: Float!, + $state: Float!, + ) { + externalVideoUpdate( + status: $status, + rate: $rate, + time: $time, + state: $state, + ) + } +`; + +export default { EXTERNAL_VIDEO_START, EXTERNAL_VIDEO_UPDATE }; diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/service.js b/bigbluebutton-html5/imports/ui/components/external-video-player/service.js index 5fc1064fec..7d23437391 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/service.js +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/service.js @@ -22,31 +22,6 @@ const stopWatching = () => { makeCall('stopWatchingExternalVideo'); }; -let lastMessage = null; - -const sendMessage = (event, data) => { - - // don't re-send repeated update messages - if (lastMessage && lastMessage.event === event - && event === 'playerUpdate' && lastMessage.time === data.time) { - return; - } - - // don't register to redis a viewer joined message - if (event === 'viewerJoined') { - return; - } - - lastMessage = { ...data, event }; - - // Use an integer for playing state - // 0: stopped 1: playing - // We might use more states in the future - data.state = data.state ? 1 : 0; - - makeCall('emitExternalVideoEvent', { status: event, playerStatus: data }); -}; - const onMessage = (message, func) => { const streamer = getStreamer(Auth.meetingID); streamer.on(message, func); @@ -65,7 +40,6 @@ const getPlayingState = (state) => { }; export { - sendMessage, onMessage, removeAllListeners, isUrlValid, From f258d7bb13a653c59e55add583c5e04eae376cbc Mon Sep 17 00:00:00 2001 From: Jesus Federico Date: Fri, 19 Jan 2024 10:19:37 -0500 Subject: [PATCH 0185/1039] fix: bbb-recording-imex/pom.xml to reduce vulnerabilities (#19275) The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JAVA-CHQOSLOGBACK-6097492 - https://snyk.io/vuln/SNYK-JAVA-CHQOSLOGBACK-6097493 Co-authored-by: snyk-bot --- bbb-recording-imex/pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) mode change 100755 => 100644 bbb-recording-imex/pom.xml diff --git a/bbb-recording-imex/pom.xml b/bbb-recording-imex/pom.xml old mode 100755 new mode 100644 index d3818141a3..533d3d4d8e --- a/bbb-recording-imex/pom.xml +++ b/bbb-recording-imex/pom.xml @@ -75,7 +75,7 @@ ch.qos.logback logback-core - 1.2.11 + 1.2.13 org.slf4j @@ -85,7 +85,7 @@ ch.qos.logback logback-classic - 1.2.11 + 1.2.13 From f4e5803b1514ee8c36aa7d7f995748b1c5f1a3a7 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Fri, 19 Jan 2024 13:36:20 -0300 Subject: [PATCH 0186/1039] Makes timer.accumulated be calculated on the backend --- .../org/bigbluebutton/core/apps/TimerModel.scala | 8 ++++++++ .../apps/timer/DeactivateTimerReqMsgHdlr.scala | 5 ++++- .../core/apps/timer/StartTimerReqMsgHdlr.scala | 2 +- .../core/apps/timer/StopTimerReqMsgHdlr.scala | 5 ++--- .../core/apps/timer/SwitchTimerReqMsgHdlr.scala | 1 + .../bigbluebutton/common2/msgs/TimerMsgs.scala | 2 +- bbb-graphql-actions/src/actions/timerStop.ts | 6 +----- bbb-graphql-server/metadata/actions.graphql | 4 +--- .../imports/ui/components/timer/component.jsx | 16 ++++------------ .../imports/ui/components/timer/container.jsx | 4 ++-- .../imports/ui/components/timer/mutations.jsx | 4 ++-- .../timer/timer-graphql/indicator/component.tsx | 8 +------- 12 files changed, 28 insertions(+), 37 deletions(-) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/TimerModel.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/TimerModel.scala index 2d5d4663ef..9924648a9e 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/TimerModel.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/TimerModel.scala @@ -45,6 +45,14 @@ object TimerModel { } def setRunning(model: TimerModel, running: Boolean): Unit = { + + //If it is running and will stop, calculate new Accumulated + if(getRunning(model) && !running) { + val now = System.currentTimeMillis() + val accumulated = getAccumulated(model) + Math.abs(now - getStartedAt(model)).toInt + this.setAccumulated(model, accumulated) + } + model.running = running } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/DeactivateTimerReqMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/DeactivateTimerReqMsgHdlr.scala index 8b13394bfc..0035fdc2ea 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/DeactivateTimerReqMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/DeactivateTimerReqMsgHdlr.scala @@ -30,7 +30,10 @@ trait DeactivateTimerReqMsgHdlr extends RightsManagementTrait { val reason = "You need to be the presenter or moderator to deactivate timer" PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting) } else { - TimerModel.setIsActive(liveMeeting.timerModel, false) + TimerModel.setRunning(liveMeeting.timerModel, running = false) + TimerModel.setIsActive(liveMeeting.timerModel, active = false) + TimerModel.setStopwatch(liveMeeting.timerModel, stopwatch = true) + TimerModel.reset(liveMeeting.timerModel) TimerDAO.update(liveMeeting.props.meetingProp.intId, liveMeeting.timerModel) broadcastEvent() } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/StartTimerReqMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/StartTimerReqMsgHdlr.scala index aa1651fd53..d2d1a7b298 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/StartTimerReqMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/StartTimerReqMsgHdlr.scala @@ -31,7 +31,7 @@ trait StartTimerReqMsgHdlr extends RightsManagementTrait { PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting) } else { TimerModel.setStartedAt(liveMeeting.timerModel, System.currentTimeMillis()) - TimerModel.setRunning(liveMeeting.timerModel, true) + TimerModel.setRunning(liveMeeting.timerModel, running = true) TimerDAO.update(liveMeeting.props.meetingProp.intId, liveMeeting.timerModel) broadcastEvent() } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/StopTimerReqMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/StopTimerReqMsgHdlr.scala index 12317a0613..178f9641ac 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/StopTimerReqMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/StopTimerReqMsgHdlr.scala @@ -33,10 +33,9 @@ trait StopTimerReqMsgHdlr extends RightsManagementTrait { val reason = "You need to be the presenter or moderator to stop timer" PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting) } else { - TimerModel.setAccumulated(liveMeeting.timerModel, msg.body.accumulated) - TimerModel.setRunning(liveMeeting.timerModel, false) + TimerModel.setRunning(liveMeeting.timerModel, running = false) TimerDAO.update(liveMeeting.props.meetingProp.intId, liveMeeting.timerModel) - broadcastEvent(msg.body.accumulated) + broadcastEvent(TimerModel.getAccumulated(liveMeeting.timerModel)) } } } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/SwitchTimerReqMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/SwitchTimerReqMsgHdlr.scala index 3f897f04d1..526eb4a4c9 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/SwitchTimerReqMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/timer/SwitchTimerReqMsgHdlr.scala @@ -34,6 +34,7 @@ trait SwitchTimerReqMsgHdlr extends RightsManagementTrait { } else { if (TimerModel.getStopwatch(liveMeeting.timerModel) != msg.body.stopwatch) { TimerModel.setStopwatch(liveMeeting.timerModel, msg.body.stopwatch) + TimerModel.setRunning(liveMeeting.timerModel, running = false) TimerModel.reset(liveMeeting.timerModel) //Reset on switch Stopwatch/Timer if (msg.body.stopwatch) { TimerModel.setTrack(liveMeeting.timerModel, "noTrack") diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/TimerMsgs.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/TimerMsgs.scala index 8e31df8412..34ebca4159 100644 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/TimerMsgs.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/TimerMsgs.scala @@ -19,7 +19,7 @@ case class StartTimerReqMsgBody() object StopTimerReqMsg { val NAME = "StopTimerReqMsg" } case class StopTimerReqMsg(header: BbbClientMsgHeader, body: StopTimerReqMsgBody) extends StandardMsg -case class StopTimerReqMsgBody(accumulated: Int) +case class StopTimerReqMsgBody() object SwitchTimerReqMsg { val NAME = "SwitchTimerReqMsg" } case class SwitchTimerReqMsg(header: BbbClientMsgHeader, body: SwitchTimerReqMsgBody) extends StandardMsg diff --git a/bbb-graphql-actions/src/actions/timerStop.ts b/bbb-graphql-actions/src/actions/timerStop.ts index 0acc6202c5..e38b1a6177 100644 --- a/bbb-graphql-actions/src/actions/timerStop.ts +++ b/bbb-graphql-actions/src/actions/timerStop.ts @@ -16,11 +16,7 @@ export default function buildRedisMessage(sessionVariables: Record { timerStart(); }; - const stopTimer = (accumulated) => { - timerStop({ variables: { accumulated } }); + const stopTimer = () => { + timerStop(); }; const switchTimer = (stopwatch) => { diff --git a/bigbluebutton-html5/imports/ui/components/timer/mutations.jsx b/bigbluebutton-html5/imports/ui/components/timer/mutations.jsx index 242d430eb6..b1a12bc1e4 100644 --- a/bigbluebutton-html5/imports/ui/components/timer/mutations.jsx +++ b/bigbluebutton-html5/imports/ui/components/timer/mutations.jsx @@ -29,8 +29,8 @@ export const TIMER_START = gql` `; export const TIMER_STOP = gql` - mutation timerStop($accumulated: Float!) { - timerStop(accumulated: $accumulated) + mutation timerStop { + timerStop } `; diff --git a/bigbluebutton-html5/imports/ui/components/timer/timer-graphql/indicator/component.tsx b/bigbluebutton-html5/imports/ui/components/timer/timer-graphql/indicator/component.tsx index 17b4f64584..3bd5938c95 100644 --- a/bigbluebutton-html5/imports/ui/components/timer/timer-graphql/indicator/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/timer/timer-graphql/indicator/component.tsx @@ -53,13 +53,7 @@ const TimerIndicator: React.FC = ({ }; const stopTimer = () => { - stopTimerMutation( - { - variables: { - accumulated: time, - }, - }, - ); + stopTimerMutation(); }; useEffect(() => { From d30b806b47458ff481976752312f3101d4e843d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Barboza=20de=20S=C3=A1?= Date: Fri, 19 Jan 2024 13:42:01 -0300 Subject: [PATCH 0187/1039] test: Fix no-flaky tests and properly set the execution mode (#19436) * test: fix shortcuts, add flaky flag for test requiring graphql data, fix slide change for tldraw v2 * test: properly set the execution mode * test: use isMultiUser parameter inside options obj * test: fix banner color test * test: increase breakout test timeouts for user joining room * test: redo the change in the hide presentation on join test * test: change hide presentation steps and add flaky flag on it --- .../audio-controls/component.tsx | 6 ++--- .../imports/ui/core/hooks/useShortcut.tsx | 2 +- .../playwright/audio/audio.spec.js | 11 +++----- .../playwright/breakout/create.js | 2 +- .../playwright/breakout/join.js | 2 +- .../playwright/chat/chat.spec.js | 14 +++++----- bigbluebutton-tests/playwright/chat/util.js | 2 +- .../playwright/core/elements.js | 4 +-- .../playwright/core/helpers.js | 13 ++++++++++ bigbluebutton-tests/playwright/core/page.js | 1 - .../playwright/layouts/layouts.spec.js | 13 +++------- .../learningdashboard.spec.js | 10 +++---- .../notifications/notifications.spec.js | 3 ++- .../playwright/options/options.spec.js | 12 ++++----- .../playwright/parameters/constants.js | 12 ++++----- .../playwright/parameters/customparameters.js | 21 ++++++++------- .../playwright/parameters/parameters.spec.js | 26 ++++++++++++------- .../playwright/parameters/util.js | 16 ++++++------ .../playwright/polling/poll.js | 24 ++++++++++------- .../playwright/polling/polling.spec.js | 13 ++++------ .../playwright/presentation/presentation.js | 1 - .../playwright/presentation/util.js | 25 +++++++++++++----- .../reconnection/reconnection.spec.js | 5 +--- .../screenshare/screenshare.spec.js | 3 ++- .../sharednotes/sharednotes.spec.js | 13 +++++----- 25 files changed, 138 insertions(+), 116 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/component.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/component.tsx index 0a00851818..214a069679 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/component.tsx @@ -51,7 +51,7 @@ const AudioControls: React.FC = ({ updateEchoTestRunning, }) => { const intl = useIntl(); - const joinAudioShourtcut = useShortcut('joinaudio'); + const joinAudioShortcut = useShortcut('joinAudio'); const echoTestIntervalRef = React.useRef>(); const [isAudioModalOpen, setIsAudioModalOpen] = React.useState(false); @@ -79,10 +79,10 @@ const AudioControls: React.FC = ({ icon="no_audio" size="lg" circle - accessKey={joinAudioShourtcut} + accessKey={joinAudioShortcut} /> ); - }, [isConnected, disabled]); + }, [isConnected, disabled, joinAudioShortcut]); useEffect(() => { if (isEchoTest) { diff --git a/bigbluebutton-html5/imports/ui/core/hooks/useShortcut.tsx b/bigbluebutton-html5/imports/ui/core/hooks/useShortcut.tsx index 832b0c4a33..5bd1ae642d 100644 --- a/bigbluebutton-html5/imports/ui/core/hooks/useShortcut.tsx +++ b/bigbluebutton-html5/imports/ui/core/hooks/useShortcut.tsx @@ -17,7 +17,7 @@ export function useShortcut(param: string): string { useEffect(() => { const ENABLED_SHORTCUTS = getFromUserSettings('bbb_shortcuts', null); const filteredShortcuts: ShortcutObject[] = Object.values(BASE_SHORTCUTS).filter( - (el: ShortcutObject) => (ENABLED_SHORTCUTS ? ENABLED_SHORTCUTS.includes(el.descId) : true), + (el: ShortcutObject) => (ENABLED_SHORTCUTS ? ENABLED_SHORTCUTS.includes(el.descId.toLowerCase()) : true), ); const shortcutsString: string = filteredShortcuts diff --git a/bigbluebutton-tests/playwright/audio/audio.spec.js b/bigbluebutton-tests/playwright/audio/audio.spec.js index 99a7aa55be..732f399b42 100644 --- a/bigbluebutton-tests/playwright/audio/audio.spec.js +++ b/bigbluebutton-tests/playwright/audio/audio.spec.js @@ -1,17 +1,14 @@ const { test } = require('@playwright/test'); const { fullyParallel } = require('../playwright.config'); const { Audio } = require('./audio'); - -if (!fullyParallel) test.describe.configure({ mode: 'serial' }); +const { initializePages } = require('../core/helpers'); test.describe('Audio', () => { const audio = new Audio(); - test.beforeAll(async ({ browser }) => { - const context = await browser.newContext(); - const page = await context.newPage(); - await audio.initModPage(page, true); - await audio.initUserPage(true, context); + test.describe.configure({ mode: fullyParallel ? 'parallel' : 'serial' }); + test[fullyParallel ? 'beforeEach' : 'beforeAll'](async ({ browser }) => { + await initializePages(audio, browser, { isMultiUser: true }); }); // https://docs.bigbluebutton.org/2.6/release-tests.html#listen-only-mode-automated diff --git a/bigbluebutton-tests/playwright/breakout/create.js b/bigbluebutton-tests/playwright/breakout/create.js index d0ecdf39ff..340b6e4a45 100644 --- a/bigbluebutton-tests/playwright/breakout/create.js +++ b/bigbluebutton-tests/playwright/breakout/create.js @@ -117,7 +117,7 @@ class Create extends MultiUsers { await this.userPage.waitAndClick(e.modalConfirmButton); await this.modPage.waitAndClick(e.breakoutRoomsItem); - await this.modPage.hasText(e.userNameBreakoutRoom, /Attendee/); + await this.modPage.hasText(e.userNameBreakoutRoom, /Attendee/, ELEMENT_WAIT_LONGER_TIME); } } diff --git a/bigbluebutton-tests/playwright/breakout/join.js b/bigbluebutton-tests/playwright/breakout/join.js index 4781f62f94..ac34b1cd55 100644 --- a/bigbluebutton-tests/playwright/breakout/join.js +++ b/bigbluebutton-tests/playwright/breakout/join.js @@ -139,7 +139,7 @@ class Join extends Create { await breakoutUserPage.page.isClosed(); await this.userPage.waitAndClick(e.modalConfirmButton); - await this.modPage.hasText(e.userNameBreakoutRoom2, /Attendee/); + await this.modPage.hasText(e.userNameBreakoutRoom2, /Attendee/, ELEMENT_WAIT_LONGER_TIME); } async exportBreakoutNotes() { diff --git a/bigbluebutton-tests/playwright/chat/chat.spec.js b/bigbluebutton-tests/playwright/chat/chat.spec.js index 9e38f9c698..0d63de8c3a 100644 --- a/bigbluebutton-tests/playwright/chat/chat.spec.js +++ b/bigbluebutton-tests/playwright/chat/chat.spec.js @@ -1,18 +1,16 @@ const { test } = require('@playwright/test'); const { fullyParallel } = require('../playwright.config'); -const { linkIssue } = require('../core/helpers'); +const { linkIssue, initializePages } = require('../core/helpers'); const { Chat } = require('./chat'); -if (!fullyParallel) test.describe.configure({ mode: 'serial' }); - test.describe('Chat', () => { const chat = new Chat(); let context; - test.beforeAll(async ({ browser }) => { - context = await browser.newContext(); - const page = await context.newPage(); - await chat.initModPage(page, true); - await chat.initUserPage(true, context); + + test.describe.configure({ mode: fullyParallel ? 'parallel' : 'serial' }); + test[fullyParallel ? 'beforeEach' : 'beforeAll'](async ({ browser }) => { + const { context: innerContext } = await initializePages(chat, browser, { isMultiUser: true }); + context = innerContext; }); // https://docs.bigbluebutton.org/2.6/release-tests.html#public-message-automated diff --git a/bigbluebutton-tests/playwright/chat/util.js b/bigbluebutton-tests/playwright/chat/util.js index a6799043d4..3022df39e7 100644 --- a/bigbluebutton-tests/playwright/chat/util.js +++ b/bigbluebutton-tests/playwright/chat/util.js @@ -1,4 +1,4 @@ -const { default: test, expect } = require('@playwright/test'); +const { expect } = require('@playwright/test'); const e = require('../core/elements'); const { getSettings } = require('../core/settings'); diff --git a/bigbluebutton-tests/playwright/core/elements.js b/bigbluebutton-tests/playwright/core/elements.js index bffcb6510d..c1c83f333f 100644 --- a/bigbluebutton-tests/playwright/core/elements.js +++ b/bigbluebutton-tests/playwright/core/elements.js @@ -276,7 +276,7 @@ exports.yesNoAbstentionOption = 'li[role="menuitem"]>>nth=1'; exports.pollAnswerOptionE = 'button[data-test="pollAnswerOption"]>>nth=4'; exports.answerE = 'div[data-test="numberOfVotes"]>>nth=4'; // Presentation -exports.currentSlideImg = 'img[id="slide-background-shape_image"]'; +exports.currentSlideImg = '[id="whiteboard-element"] [class="tl-image"]'; exports.uploadPresentationFileName = 'uploadTest.png'; exports.presentationPPTX = 'BBB.pptx'; exports.presentationTXT = 'helloWorld.txt'; @@ -285,7 +285,7 @@ exports.noPresentationLabel = 'There is no currently active presentation'; exports.startScreenSharing = 'button[data-test="startScreenShare"]'; exports.stopScreenSharing = 'button[data-test="stopScreenShare"]'; exports.managePresentations = 'li[data-test="managePresentations"]'; -exports.fileUpload = 'input[type="file"]'; +exports.presentationFileUpload = 'div#upload-modal input[type="file"]'; exports.presentationToolbarWrapper = 'div[id="presentationToolbarWrapper"]'; exports.nextSlide = 'button[data-test="nextSlide"]'; exports.prevSlide = 'button[data-test="prevSlide"]'; diff --git a/bigbluebutton-tests/playwright/core/helpers.js b/bigbluebutton-tests/playwright/core/helpers.js index f497d7e70f..700ae53c6d 100644 --- a/bigbluebutton-tests/playwright/core/helpers.js +++ b/bigbluebutton-tests/playwright/core/helpers.js @@ -162,6 +162,18 @@ function sleep(time) { }); } +async function initializePages(testInstance, browser, initOptions) { + const { isMultiUser, createParameter, joinParameter } = initOptions || {}; + const context = await browser.newContext(); + const page = await context.newPage(); + await testInstance.initModPage(page, true, { createParameter, joinParameter }); + if (isMultiUser) await testInstance.initUserPage(true, context, { createParameter, joinParameter }); + + return { + context, + }; +} + exports.getRandomInt = getRandomInt; exports.apiCallUrl = apiCallUrl; exports.apiCall = apiCall; @@ -173,3 +185,4 @@ exports.checkRootPermission = checkRootPermission; exports.linkIssue = linkIssue; exports.sleep = sleep; exports.setBrowserLogs = setBrowserLogs; +exports.initializePages = initializePages; diff --git a/bigbluebutton-tests/playwright/core/page.js b/bigbluebutton-tests/playwright/core/page.js index dbe6c8f5c6..ee7029814c 100644 --- a/bigbluebutton-tests/playwright/core/page.js +++ b/bigbluebutton-tests/playwright/core/page.js @@ -7,7 +7,6 @@ const helpers = require('./helpers'); const e = require('./elements'); const { env } = require('node:process'); const { ELEMENT_WAIT_TIME, ELEMENT_WAIT_LONGER_TIME, VIDEO_LOADING_WAIT_TIME } = require('./constants'); -const { recordMeeting } = require('../parameters/constants'); const { checkElement, checkElementLengthEqualTo } = require('./util'); const { generateSettingsData, getSettings } = require('./settings'); diff --git a/bigbluebutton-tests/playwright/layouts/layouts.spec.js b/bigbluebutton-tests/playwright/layouts/layouts.spec.js index f8ac086b5d..b2e4af44a4 100644 --- a/bigbluebutton-tests/playwright/layouts/layouts.spec.js +++ b/bigbluebutton-tests/playwright/layouts/layouts.spec.js @@ -3,21 +3,16 @@ const { fullyParallel } = require('../playwright.config'); const { encodeCustomParams } = require('../parameters/util'); const { PARAMETER_HIDE_PRESENTATION_TOAST } = require('../core/constants'); const { Layouts } = require('./layouts'); +const { initializePages } = require('../core/helpers'); const hidePresentationToast = encodeCustomParams(PARAMETER_HIDE_PRESENTATION_TOAST); -const CUSTOM_MEETING_ID = 'layout_management_meeting'; - -if (!fullyParallel) test.describe.configure({ mode: 'serial' }); - test.describe("Layout management", () => { const layouts = new Layouts(); - test.beforeAll(async ({ browser }) => { - const context = await browser.newContext(); - const page = await context.newPage(); - await layouts.initModPage(page, true, { createParameter: hidePresentationToast, customMeetingId: CUSTOM_MEETING_ID }); - await layouts.initUserPage(true, context, { createParameter: hidePresentationToast }); + test.describe.configure({ mode: fullyParallel ? 'parallel' : 'serial' }); + test[fullyParallel ? 'beforeEach' : 'beforeAll'](async ({ browser }) => { + await initializePages(layouts, browser, true, { isMultiUser: true, createParameter: hidePresentationToast }); await layouts.modPage.shareWebcam(); await layouts.userPage.shareWebcam(); }); diff --git a/bigbluebutton-tests/playwright/learningdashboard/learningdashboard.spec.js b/bigbluebutton-tests/playwright/learningdashboard/learningdashboard.spec.js index 355d69a831..470c84010b 100644 --- a/bigbluebutton-tests/playwright/learningdashboard/learningdashboard.spec.js +++ b/bigbluebutton-tests/playwright/learningdashboard/learningdashboard.spec.js @@ -2,16 +2,14 @@ const { test } = require('@playwright/test'); const { fullyParallel } = require('../playwright.config'); const { LearningDashboard } = require('./learningdashboard'); const c = require('../parameters/constants'); - -if (!fullyParallel) test.describe.configure({ mode: 'serial' }); +const { initializePages } = require('../core/helpers'); test.describe('Learning Dashboard', async () => { const learningDashboard = new LearningDashboard(); - test.beforeAll(async ({ browser }) => { - const context = await browser.newContext(); - const page = await context.newPage(); - await learningDashboard.initModPage(page, true, { createParameter: c.recordMeeting }); + test.describe.configure({ mode: fullyParallel ? 'parallel' : 'serial' }); + test[fullyParallel ? 'beforeEach' : 'beforeAll'](async ({ browser }) => { + const { context } = await initializePages(learningDashboard, browser, { createParameter: c.recordMeeting }); await learningDashboard.getDashboardPage(context); }); diff --git a/bigbluebutton-tests/playwright/notifications/notifications.spec.js b/bigbluebutton-tests/playwright/notifications/notifications.spec.js index 97159bf62f..05d93cc3ad 100644 --- a/bigbluebutton-tests/playwright/notifications/notifications.spec.js +++ b/bigbluebutton-tests/playwright/notifications/notifications.spec.js @@ -31,13 +31,14 @@ test.describe.parallel('Notifications', () => { }); test.describe.parallel('Chat', () => { + // both tests are flaky due to missing refactor to get data from GraphQL test('Public Chat notification @ci @flaky', async ({ browser, context, page }) => { const chatNotifications = new ChatNotifications(browser, context); await chatNotifications.initPages(page, true); await chatNotifications.publicChatNotification(); }); - test('Private Chat notification', async ({ browser, context, page }) => { + test('Private Chat notification @flaky', async ({ browser, context, page }) => { const chatNotifications = new ChatNotifications(browser, context); await chatNotifications.initPages(page, true); await chatNotifications.privateChatNotification(); diff --git a/bigbluebutton-tests/playwright/options/options.spec.js b/bigbluebutton-tests/playwright/options/options.spec.js index 9bd12c0196..f73dee0056 100644 --- a/bigbluebutton-tests/playwright/options/options.spec.js +++ b/bigbluebutton-tests/playwright/options/options.spec.js @@ -1,16 +1,16 @@ const { test } = require('@playwright/test'); const { fullyParallel } = require('../playwright.config'); const { Options } = require('./options'); - -if (!fullyParallel) test.describe.configure({ mode: 'serial' }); +const { initializePages } = require('../core/helpers'); test.describe('Options', () => { const options = new Options(); let context; - test.beforeAll(async ({ browser }) => { - context = await browser.newContext(); - const page = await context.newPage(); - await options.initModPage(page, true); + + test.describe.configure({ mode: fullyParallel ? 'parallel' : 'serial' }); + test[fullyParallel ? 'beforeEach' : 'beforeAll'](async ({ browser }) => { + const { context: innerContext } = await initializePages(options, browser); + context = innerContext; }); test('Open about modal', async () => { diff --git a/bigbluebutton-tests/playwright/parameters/constants.js b/bigbluebutton-tests/playwright/parameters/constants.js index bdd2fdb70a..1004cead70 100644 --- a/bigbluebutton-tests/playwright/parameters/constants.js +++ b/bigbluebutton-tests/playwright/parameters/constants.js @@ -1,10 +1,10 @@ const e = require('../core/elements'); // Create Parameters -exports.bannerText = 'bannerText=some text'; -const color = 'FFFF00' +exports.bannerText = 'bannerText=some+text'; +const color = '#FFFF00' exports.color = color; -exports.bannerColor = `bannerColor=%23${color}`; +exports.bannerColor = `bannerColor=${color}`; exports.maxParticipants = 'maxParticipants=2'; exports.duration = 'duration=2'; const messageModerator = 'Test'; @@ -35,13 +35,13 @@ exports.logo = 'logo=https://bigbluebutton.org/wp-content/uploads/2021/01/BigBlu exports.enableVideo = 'userdata-bbb_enable_video=false'; exports.autoShareWebcam = 'userdata-bbb_auto_share_webcam=true'; exports.multiUserPenOnly = 'userdata-bbb_multi_user_pen_only=true'; -exports.presenterTools = 'userdata-bbb_presenter_tools=["pencil", "hand"]'; -exports.multiUserTools = 'userdata-bbb_multi_user_tools=["pencil", "hand"]'; +exports.presenterTools = 'userdata-bbb_presenter_tools=["pencil","hand"]'; +exports.multiUserTools = 'userdata-bbb_multi_user_tools=["pencil","hand"]'; const cssCode = `${e.presentationTitle}{display: none;}`; exports.customStyle = `userdata-bbb_custom_style=${cssCode}`; exports.customStyleUrl = 'userdata-bbb_custom_style_url=https://develop.bigbluebutton.org/css-test-file.css'; exports.autoSwapLayout = 'userdata-bbb_auto_swap_layout=true'; -exports.hidePresentationOnJoin = 'userdata-bbb_hide_presentation_on_join="true"'; +exports.hidePresentationOnJoin = 'userdata-bbb_hide_presentation_on_join=true'; exports.outsideToggleSelfVoice = 'userdata-bbb_outside_toggle_self_voice=true'; exports.outsideToggleRecording = 'userdata-bbb_outside_toggle_recording=true'; exports.showPublicChatOnLogin = 'userdata-bbb_show_public_chat_on_login=false'; diff --git a/bigbluebutton-tests/playwright/parameters/customparameters.js b/bigbluebutton-tests/playwright/parameters/customparameters.js index acada0c773..7e641eac03 100644 --- a/bigbluebutton-tests/playwright/parameters/customparameters.js +++ b/bigbluebutton-tests/playwright/parameters/customparameters.js @@ -26,7 +26,7 @@ class CustomParameters extends MultiUsers { async clientTitle() { const pageTitle = await this.modPage.page.title(); - await expect(pageTitle).toContain(`${c.docTitle} - `); + expect(pageTitle).toContain(`${c.docTitle} - `); } async askForFeedbackOnLogout() { @@ -59,7 +59,7 @@ class CustomParameters extends MultiUsers { const resp = await this.modPage.page.evaluate((elem) => { return document.querySelectorAll(elem)[0].offsetHeight == 0; }, e.presentationTitle); - await expect(resp).toBeTruthy(); + expect(resp).toBeTruthy(); } async autoSwapLayout() { @@ -68,7 +68,7 @@ class CustomParameters extends MultiUsers { const resp = await this.modPage.page.evaluate((elem) => { return document.querySelectorAll(elem)[0].offsetHeight !== 0; }, e.restorePresentation); - await expect(resp).toBeTruthy(); + expect(resp).toBeTruthy(); } async autoJoin() { @@ -121,13 +121,14 @@ class CustomParameters extends MultiUsers { const notificationBarColor = await notificationLocator.evaluate((elem) => { return getComputedStyle(elem).backgroundColor; }, e.notificationBannerBar); - await expect(notificationBarColor).toBe(colorToRGB); + expect(notificationBarColor).toBe(colorToRGB); } async hidePresentationOnJoin() { await this.modPage.waitForSelector(e.actions); await this.modPage.hasElement(e.restorePresentation); - await this.modPage.wasRemoved(e.presentationPlaceholder); + await this.userPage.hasElement(e.restorePresentation); + await this.userPage.wasRemoved(e.whiteboard); } async forceRestorePresentationOnNewEvents(joinParameter) { @@ -135,9 +136,9 @@ class CustomParameters extends MultiUsers { const { presentationHidden, pollEnabled } = getSettings(); if (!presentationHidden) await this.userPage.waitAndClick(e.minimizePresentation); const zoomInCase = await util.zoomIn(this.modPage); - await expect(zoomInCase).toBeTruthy(); + expect(zoomInCase).toBeTruthy(); const zoomOutCase = await util.zoomOut(this.modPage); - await expect(zoomOutCase).toBeTruthy(); + expect(zoomOutCase).toBeTruthy(); if (pollEnabled) await util.poll(this.modPage, this.userPage); await util.nextSlide(this.modPage); await util.previousSlide(this.modPage); @@ -183,7 +184,7 @@ class CustomParameters extends MultiUsers { const resp = await this.userPage.page.evaluate((toolsElement) => { return document.querySelectorAll(toolsElement)[0].parentElement.childElementCount === 1; }, e.wbToolbar); - await expect(resp).toBeTruthy(); + expect(resp).toBeTruthy(); } async presenterTools() { @@ -192,7 +193,7 @@ class CustomParameters extends MultiUsers { const resp = await this.modPage.page.evaluate(([toolsElement, toolbarListSelector]) => { return document.querySelectorAll(toolsElement)[0].parentElement.querySelector(toolbarListSelector).childElementCount === 2; }, [e.wbToolbar, e.toolbarToolsList]); - await expect(resp).toBeTruthy(); + expect(resp).toBeTruthy(); } async multiUserTools() { @@ -201,7 +202,7 @@ class CustomParameters extends MultiUsers { const resp = await this.userPage.page.evaluate(([toolsElement, toolbarListSelector]) => { return document.querySelectorAll(toolsElement)[0].parentElement.querySelector(toolbarListSelector).childElementCount === 2; }, [e.wbToolbar, e.toolbarToolsList]); - await expect(resp).toBeTruthy(); + expect(resp).toBeTruthy(); } async autoShareWebcam() { diff --git a/bigbluebutton-tests/playwright/parameters/parameters.spec.js b/bigbluebutton-tests/playwright/parameters/parameters.spec.js index 0570ade66f..79a7f1bfff 100644 --- a/bigbluebutton-tests/playwright/parameters/parameters.spec.js +++ b/bigbluebutton-tests/playwright/parameters/parameters.spec.js @@ -15,26 +15,28 @@ test.describe.parallel('Create Parameters', () => { test.describe.parallel('Banner', () => { test('Banner Text @ci', async ({ browser, context, page }) => { const createParam = new CreateParameters(browser, context); - await createParam.initModPage(page, true, { createParameter: encodeCustomParams(c.bannerText) }); + await createParam.initModPage(page, true, { createParameter: c.bannerText }); await createParam.bannerText(); }); test('Banner Color @ci', async ({ browser, context, page }) => { const createParam = new CreateParameters(browser, context); - const colorToRGB = hexToRgb(c.color); - await createParam.initModPage(page, true, { createParameter: `${c.bannerColor}&${encodeCustomParams(c.bannerText)}` }); + const colorToRGB = hexToRgb(c.color.substring(1)); + await createParam.initModPage(page, true, { createParameter: `${encodeCustomParams(c.bannerColor)}&${c.bannerText}` }); await createParam.bannerColor(colorToRGB); }); }); - test('Max Participants', async ({ browser, context, page }) => { + // see https://github.com/bigbluebutton/bigbluebutton/issues/19426 + test('Max Participants @flaky', async ({ browser, context, page }) => { const createParam = new CreateParameters(browser, context); await createParam.initModPage(page, true, { createParameter: c.maxParticipants }); await createParam.initModPage2(true, context); await createParam.maxParticipants(context); }); - test('Meeting Duration', async ({ browser, context, page }) => { + // Not working due to missing data provided by GraphQL + test('Meeting Duration @flaky', async ({ browser, context, page }) => { const createParam = new CreateParameters(browser, context); await createParam.initModPage(page, true, { createParameter: c.duration }); await createParam.duration(); @@ -369,7 +371,7 @@ test.describe.parallel('Custom Parameters', () => { await customParam.displayBrandingArea(); }); - test('Shortcuts', async ({ browser, context, page }) => { + test('Shortcuts @ci', async ({ browser, context, page }) => { const customParam = new CustomParameters(browser, context); const shortcutParam = getAllShortcutParams(); await customParam.initModPage(page, true, { joinParameter: encodeCustomParams(shortcutParam) }); @@ -421,7 +423,8 @@ test.describe.parallel('Custom Parameters', () => { }); test.describe.parallel('Audio', () => { - test('Auto join @ci', async ({ browser, context, page }) => { + // see https://github.com/bigbluebutton/bigbluebutton/issues/19427 + test('Auto join @ci @flaky', async ({ browser, context, page }) => { const customParam = new CustomParameters(browser, context); await customParam.initModPage(page, false, { joinParameter: c.autoJoin }); await customParam.autoJoin(); @@ -433,7 +436,8 @@ test.describe.parallel('Custom Parameters', () => { await customParam.listenOnlyMode(); }); - test('Force Listen Only @ci', async ({ browser, context, page }) => { + // see https://github.com/bigbluebutton/bigbluebutton/issues/19428 + test('Force Listen Only @ci @flaky', async ({ browser, context, page }) => { const customParam = new CustomParameters(browser, context); await customParam.initUserPage(false, context, { useModMeetingId: false, joinParameter: c.forceListenOnly }); await customParam.forceListenOnly(page); @@ -453,9 +457,11 @@ test.describe.parallel('Custom Parameters', () => { }); test.describe.parallel('Presentation', () => { - test('Hide Presentation on join @ci', async ({ browser, context, page }) => { + // see https://github.com/bigbluebutton/bigbluebutton/issues/19456 + test('Hide Presentation on join @ci @flaky', async ({ browser, context, page }) => { const customParam = new CustomParameters(browser, context); - await customParam.initModPage(page, true, { joinParameter: encodeCustomParams(c.hidePresentationOnJoin) }); + await customParam.initModPage(page, true, { joinParameter: c.hidePresentationOnJoin }); + await customParam.initUserPage(true, context, { useModMeetingId: true, joinParameter: c.hidePresentationOnJoin }); await customParam.hidePresentationOnJoin(); }); diff --git a/bigbluebutton-tests/playwright/parameters/util.js b/bigbluebutton-tests/playwright/parameters/util.js index 07d90a9ba7..50a2eb0ee8 100644 --- a/bigbluebutton-tests/playwright/parameters/util.js +++ b/bigbluebutton-tests/playwright/parameters/util.js @@ -1,7 +1,7 @@ const { expect } = require('@playwright/test'); const e = require('../core/elements'); const c = require('./constants'); -const { ELEMENT_WAIT_TIME, ELEMENT_WAIT_LONGER_TIME } = require('../core/constants'); +const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants'); async function forceListenOnly(test) { await test.wasRemoved(e.echoYesButton); @@ -71,14 +71,14 @@ async function annotation(test) { function encodeCustomParams(param) { try { - let splited = param.split('='); - if (splited.length > 2) { - const aux = splited.shift(); - splited[1] = splited.join('='); - splited[0] = aux; + let splitted = param.split('='); + if (splitted.length > 2) { + const aux = splitted.shift(); + splitted[1] = splitted.join('='); + splitted[0] = aux; } - splited[1] = encodeURIComponent(splited[1]).replace(/%20/g, '+'); - return splited.join('='); + splitted[1] = encodeURIComponent(splitted[1]).replace(); + return splitted.join('='); } catch (err) { console.log(err); } diff --git a/bigbluebutton-tests/playwright/polling/poll.js b/bigbluebutton-tests/playwright/polling/poll.js index ddf3283071..2cce8f2272 100644 --- a/bigbluebutton-tests/playwright/polling/poll.js +++ b/bigbluebutton-tests/playwright/polling/poll.js @@ -2,10 +2,11 @@ const { expect, test } = require('@playwright/test'); const { MultiUsers } = require('../user/multiusers'); const e = require('../core/elements'); const util = require('./util.js'); -const utilPresentation = require('../presentation/util'); const { ELEMENT_WAIT_LONGER_TIME, ELEMENT_WAIT_TIME } = require('../core/constants'); const { getSettings } = require('../core/settings'); const { waitAndClearDefaultPresentationNotification } = require('../notifications/util'); +const { uploadSinglePresentation, skipSlide } = require('../presentation/util'); +const { sleep } = require('../core/helpers.js'); class Polling extends MultiUsers { constructor(browser, context) { @@ -33,7 +34,7 @@ class Polling extends MultiUsers { } async quickPoll() { - await utilPresentation.uploadSinglePresentation(this.modPage, e.questionSlideFileName); + await uploadSinglePresentation(this.modPage, e.questionSlideFileName); // The slide needs to be uploaded and converted, so wait a bit longer for this step await this.modPage.waitAndClick(e.quickPoll, ELEMENT_WAIT_LONGER_TIME); @@ -117,6 +118,7 @@ class Polling extends MultiUsers { } async notAbleStartNewPollWithoutPresentation() { + await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME); await this.modPage.waitAndClick(e.actions); await this.modPage.waitAndClick(e.managePresentations); await this.modPage.waitAndClick(e.removePresentation); @@ -128,7 +130,7 @@ class Polling extends MultiUsers { } async customInput() { - await utilPresentation.uploadSinglePresentation(this.modPage, e.questionSlideFileName); + await uploadSinglePresentation(this.modPage, e.questionSlideFileName); await this.modPage.waitAndClick(e.actions); await this.modPage.waitAndClick(e.polling); @@ -180,7 +182,7 @@ class Polling extends MultiUsers { async smartSlidesQuestions() { await this.modPage.hasElement(e.whiteboard, ELEMENT_WAIT_LONGER_TIME); - await utilPresentation.uploadSinglePresentation(this.modPage, e.smartSlides1, ELEMENT_WAIT_LONGER_TIME); + await uploadSinglePresentation(this.modPage, e.smartSlides1, ELEMENT_WAIT_LONGER_TIME); await this.userPage.hasElement(e.userListItem); // Type Response @@ -196,7 +198,8 @@ class Polling extends MultiUsers { await this.modPage.wasRemoved(e.closePollingBtn); // Multiple Choices - await this.modPage.waitAndClick(e.nextSlide); + await sleep(500); // avoid error when the tooltip is in front of the button due to layout shift + await skipSlide(this.modPage); await this.modPage.waitAndClick(e.quickPoll); await this.userPage.waitAndClick(e.firstPollAnswerDescOption); await this.userPage.waitAndClick(e.secondPollAnswerDescOption); @@ -209,7 +212,8 @@ class Polling extends MultiUsers { await this.modPage.wasRemoved(e.closePollingBtn); // One option answer - await this.modPage.waitAndClick(e.nextSlide); + await sleep(500); // avoid error when the tooltip is in front of the button due to layout shift + await skipSlide(this.modPage); await this.modPage.waitAndClick(e.quickPoll); await this.userPage.waitAndClick(e.pollAnswerOptionE); await this.modPage.hasText(e.answerE, '1'); @@ -219,7 +223,8 @@ class Polling extends MultiUsers { await this.modPage.wasRemoved(e.closePollingBtn); // Yes/No/Abstention - await this.modPage.waitAndClick(e.nextSlide); + await sleep(500); // avoid error when the tooltip is in front of the button due to layout shift + await skipSlide(this.modPage); await this.modPage.waitAndClick(e.yesNoOption); await this.modPage.waitAndClick(e.yesNoAbstentionOption) await this.userPage.waitAndClick(e.pollAnswerOptionBtn); @@ -230,7 +235,8 @@ class Polling extends MultiUsers { await this.modPage.wasRemoved(e.closePollingBtn); // True/False - await this.modPage.waitAndClick(e.nextSlide); + await sleep(500); // avoid error when the tooltip is in front of the button due to layout shift + await skipSlide(this.modPage); await this.modPage.waitAndClick(e.quickPoll); await this.userPage.waitAndClick(e.pollAnswerOptionBtn); await this.modPage.hasText(e.answer1, '1'); @@ -293,7 +299,7 @@ class Polling extends MultiUsers { await waitAndClearDefaultPresentationNotification(this.modPage); - await utilPresentation.uploadSinglePresentation(this.modPage, e.questionSlideFileName); + await uploadSinglePresentation(this.modPage, e.questionSlideFileName); await util.startPoll(this.modPage); await this.modPage.waitAndClick(e.publishPollingLabel); diff --git a/bigbluebutton-tests/playwright/polling/polling.spec.js b/bigbluebutton-tests/playwright/polling/polling.spec.js index bcf1b0cab1..e69f8fa332 100644 --- a/bigbluebutton-tests/playwright/polling/polling.spec.js +++ b/bigbluebutton-tests/playwright/polling/polling.spec.js @@ -1,17 +1,14 @@ const { test } = require('@playwright/test'); const { fullyParallel } = require('../playwright.config'); const { Polling } = require('./poll'); +const { initializePages } = require('../core/helpers'); -if (!fullyParallel) test.describe.configure({ mode: 'serial' }); - -test.describe('Polling', () => { +test.describe('Polling', async () => { const polling = new Polling(); - test.beforeAll(async ({ browser }) => { - const context = await browser.newContext(); - const page = await context.newPage(); - await polling.initModPage(page, true); - await polling.initUserPage(true, context); + test.describe.configure({ mode: fullyParallel ? 'parallel' : 'serial' }); + test[fullyParallel ? 'beforeEach' : 'beforeAll'](async ({ browser }) => { + await initializePages(polling, browser, { isMultiUser: true }); }); // Manage diff --git a/bigbluebutton-tests/playwright/presentation/presentation.js b/bigbluebutton-tests/playwright/presentation/presentation.js index f0137f6668..b5869b4dfb 100644 --- a/bigbluebutton-tests/playwright/presentation/presentation.js +++ b/bigbluebutton-tests/playwright/presentation/presentation.js @@ -6,7 +6,6 @@ const { checkSvgIndex, getSlideOuterHtml, uploadSinglePresentation, uploadMultip const { ELEMENT_WAIT_LONGER_TIME, ELEMENT_WAIT_EXTRA_LONG_TIME, UPLOAD_PDF_WAIT_TIME, ELEMENT_WAIT_TIME } = require('../core/constants'); const { sleep } = require('../core/helpers'); const { getSettings } = require('../core/settings'); -const { waitAndClearDefaultPresentationNotification, waitAndClearNotification } = require('../notifications/util'); const defaultZoomLevel = '100%'; diff --git a/bigbluebutton-tests/playwright/presentation/util.js b/bigbluebutton-tests/playwright/presentation/util.js index 106bb855e7..9152a85ab4 100644 --- a/bigbluebutton-tests/playwright/presentation/util.js +++ b/bigbluebutton-tests/playwright/presentation/util.js @@ -23,18 +23,23 @@ async function getCurrentPresentationHeight(locator) { } async function uploadSinglePresentation(test, fileName, uploadTimeout = UPLOAD_PDF_WAIT_TIME) { - const firstSlideSrc = await test.page.evaluate(selector => document.querySelector(selector).src, [e.currentSlideImg]); + const firstSlideSrc = await test.page.evaluate(selector => document.querySelector(selector) + .style + .backgroundImage + .split('"')[1], + [e.currentSlideImg]); await test.waitAndClick(e.actions); await test.waitAndClick(e.managePresentations); - await test.waitForSelector(e.fileUpload); + await test.waitForSelector(e.presentationFileUpload); - await test.page.setInputFiles(e.fileUpload, path.join(__dirname, `../core/media/${fileName}`)); + await test.page.setInputFiles(e.presentationFileUpload, path.join(__dirname, `../core/media/${fileName}`)); await test.hasText('body', e.statingUploadPresentationToast); await test.waitAndClick(e.confirmManagePresentation); await test.hasElement(e.presentationUploadProgressToast, ELEMENT_WAIT_EXTRA_LONG_TIME); await test.page.waitForFunction(([selector, firstSlideSrc]) => { - const currentSrc = document.querySelector(selector).src; + const currentSrc = document.querySelector(selector) + ?.style?.backgroundImage?.split('"')[1]; return currentSrc != firstSlideSrc; }, [e.currentSlideImg, firstSlideSrc], { timeout: uploadTimeout, @@ -44,9 +49,9 @@ async function uploadSinglePresentation(test, fileName, uploadTimeout = UPLOAD_P async function uploadMultiplePresentations(test, fileNames, uploadTimeout = ELEMENT_WAIT_EXTRA_LONG_TIME) { await test.waitAndClick(e.actions); await test.waitAndClick(e.managePresentations); - await test.waitForSelector(e.fileUpload); + await test.waitForSelector(e.presentationFileUpload); - await test.page.setInputFiles(e.fileUpload, fileNames.map((fileName) => path.join(__dirname, `../core/media/${fileName}`))); + await test.page.setInputFiles(e.presentationFileUpload, fileNames.map((fileName) => path.join(__dirname, `../core/media/${fileName}`))); await test.hasText('body', e.statingUploadPresentationToast); await test.waitAndClick(e.confirmManagePresentation); @@ -54,8 +59,16 @@ async function uploadMultiplePresentations(test, fileNames, uploadTimeout = ELEM await test.hasText(e.smallToastMsg, e.presentationUploadedToast, uploadTimeout); } +async function skipSlide(page) { + const selectSlideLocator = page.getLocator(e.skipSlide); + const currentSlideNumber = await selectSlideLocator.inputValue(); + await page.waitAndClick(e.nextSlide); + await expect(selectSlideLocator).not.toHaveValue(currentSlideNumber); +} + exports.checkSvgIndex = checkSvgIndex; exports.getSlideOuterHtml = getSlideOuterHtml; exports.uploadSinglePresentation = uploadSinglePresentation; exports.uploadMultiplePresentations = uploadMultiplePresentations; exports.getCurrentPresentationHeight = getCurrentPresentationHeight; +exports.skipSlide = skipSlide; diff --git a/bigbluebutton-tests/playwright/reconnection/reconnection.spec.js b/bigbluebutton-tests/playwright/reconnection/reconnection.spec.js index 039426f396..2d328e0853 100644 --- a/bigbluebutton-tests/playwright/reconnection/reconnection.spec.js +++ b/bigbluebutton-tests/playwright/reconnection/reconnection.spec.js @@ -1,11 +1,8 @@ const { test } = require('@playwright/test'); -const { fullyParallel } = require('../playwright.config'); const { Reconnection } = require('./reconnection'); const { checkRootPermission } = require('../core/helpers'); -if (!fullyParallel) test.describe.configure({ mode: 'serial' }); - -test.describe('Reconnection', () => { +test.describe.parallel('Reconnection', () => { test('Chat', async ({ browser, context, page }) => { await checkRootPermission(); // check sudo permission before starting test const reconnection = new Reconnection(browser, context); diff --git a/bigbluebutton-tests/playwright/screenshare/screenshare.spec.js b/bigbluebutton-tests/playwright/screenshare/screenshare.spec.js index fc60476009..e92e104107 100644 --- a/bigbluebutton-tests/playwright/screenshare/screenshare.spec.js +++ b/bigbluebutton-tests/playwright/screenshare/screenshare.spec.js @@ -5,7 +5,8 @@ test.describe.parallel('Screenshare', () => { // https://docs.bigbluebutton.org/2.6/release-tests.html#sharing-screen-in-full-screen-mode-automated test('Share screen @ci', async ({ browser, browserName, page }) => { test.skip(browserName === 'firefox' && process.env.DISPLAY === undefined, - "Screenshare tests not able in Firefox browser without desktop"); + 'Screenshare tests not able in Firefox browser without desktop' + ); const screenshare = new ScreenShare(browser, page); await screenshare.init(true, true); await screenshare.startSharing(); diff --git a/bigbluebutton-tests/playwright/sharednotes/sharednotes.spec.js b/bigbluebutton-tests/playwright/sharednotes/sharednotes.spec.js index 5d1f00da6f..198928ef34 100644 --- a/bigbluebutton-tests/playwright/sharednotes/sharednotes.spec.js +++ b/bigbluebutton-tests/playwright/sharednotes/sharednotes.spec.js @@ -1,15 +1,16 @@ const { test } = require('@playwright/test'); const { SharedNotes } = require('./sharednotes'); +const { initializePages } = require('../core/helpers'); +const { fullyParallel } = require('../playwright.config'); -test.describe.parallel('Shared Notes', () => { +test.describe('Shared Notes', () => { const sharedNotes = new SharedNotes(); - test.beforeAll(async ({ browser }) => { - const context = await browser.newContext(); - const page = await context.newPage(); - await sharedNotes.initModPage(page, true); - await sharedNotes.initUserPage(true, context); + test.describe.configure({ mode: fullyParallel ? 'parallel' : 'serial' }); + test[fullyParallel ? 'beforeEach' : 'beforeAll'](async ({ browser }) => { + await initializePages(sharedNotes, browser, { isMultiUser: true }); }); + test('Open shared notes @ci', async () => { await sharedNotes.openSharedNotes(); }); From 2f67417b4b02c8e15a1008be876a630e7176ad1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Fri, 19 Jan 2024 15:40:27 -0300 Subject: [PATCH 0188/1039] migrate stopWatchingExternalVideo action --- .../api/external-videos/server/index.js | 1 - .../api/external-videos/server/methods.js | 6 - .../methods/stopWatchingExternalVideo.js | 25 ---- .../ui/components/actions-bar/container.jsx | 8 +- .../actions-bar/screenshare/component.jsx | 6 +- .../modal/service.ts | 7 +- .../external-video-player/mutations.tsx | 8 +- .../external-video-player/service.js | 6 - .../notes/notes-dropdown/component.jsx | 3 +- .../notes/notes-dropdown/container.jsx | 19 +++- .../notes/notes-dropdown/service.js | 2 +- .../imports/ui/components/notes/service.js | 4 +- .../imports/ui/components/pads/service.js | 5 +- .../ui/components/screenshare/component.jsx | 3 +- .../ui/components/screenshare/container.jsx | 4 + .../ui/components/screenshare/service.js | 3 +- .../ui/components/video-preview/container.jsx | 107 ++++++++++-------- bigbluebutton-html5/server/main.js | 1 - 18 files changed, 105 insertions(+), 113 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/external-videos/server/index.js delete mode 100644 bigbluebutton-html5/imports/api/external-videos/server/methods.js delete mode 100644 bigbluebutton-html5/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js diff --git a/bigbluebutton-html5/imports/api/external-videos/server/index.js b/bigbluebutton-html5/imports/api/external-videos/server/index.js deleted file mode 100644 index ba55c4a16e..0000000000 --- a/bigbluebutton-html5/imports/api/external-videos/server/index.js +++ /dev/null @@ -1 +0,0 @@ -import './methods'; diff --git a/bigbluebutton-html5/imports/api/external-videos/server/methods.js b/bigbluebutton-html5/imports/api/external-videos/server/methods.js deleted file mode 100644 index 710daa890b..0000000000 --- a/bigbluebutton-html5/imports/api/external-videos/server/methods.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import stopWatchingExternalVideo from './methods/stopWatchingExternalVideo'; - -Meteor.methods({ - stopWatchingExternalVideo, -}); diff --git a/bigbluebutton-html5/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js b/bigbluebutton-html5/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js deleted file mode 100644 index 59bc829d54..0000000000 --- a/bigbluebutton-html5/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js +++ /dev/null @@ -1,25 +0,0 @@ -import { check } from 'meteor/check'; -import Logger from '/imports/startup/server/logger'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import RedisPubSub from '/imports/startup/server/redis'; - -export default function stopWatchingExternalVideo() { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'StopExternalVideoPubMsg'; - - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - - const payload = {}; - - Logger.info(`User ${requesterUserId} stoping an external video for meeting ${meetingId}`); - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (error) { - Logger.error(`Error on stoping an external video for meeting ${meetingId}: ${error}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/container.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/container.jsx index 62a1dc5052..9baac5016f 100644 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/container.jsx @@ -2,12 +2,11 @@ import React, { useContext } from 'react'; import { Meteor } from 'meteor/meteor'; import { withTracker } from 'meteor/react-meteor-data'; import { injectIntl } from 'react-intl'; -import { useSubscription } from '@apollo/client'; +import { useSubscription, useMutation } from '@apollo/client'; import getFromUserSettings from '/imports/ui/services/users-settings'; import Auth from '/imports/ui/services/auth'; import ActionsBar from './component'; import Service from './service'; -import ExternalVideoService from '/imports/ui/components/external-video-player/service'; import CaptionsService from '/imports/ui/components/captions/service'; import TimerService from '/imports/ui/components/timer/service'; import { layoutSelectOutput, layoutDispatch } from '../layout/context'; @@ -20,6 +19,7 @@ import { import MediaService from '../media/service'; import useMeeting from '/imports/ui/core/hooks/useMeeting'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import { EXTERNAL_VIDEO_STOP } from '../external-video-player/mutations'; const ActionsBarContainer = (props) => { const actionsBarStyle = layoutSelectOutput((i) => i.actionBar); @@ -50,6 +50,8 @@ const ActionsBarContainer = (props) => { emoji: user.emoji, isModerator: user.isModerator, })); + + const [stopExternalVideoShare] = useMutation(EXTERNAL_VIDEO_STOP); const currentUser = { userId: Auth.userID, emoji: currentUserData?.emoji }; const amIPresenter = currentUserData?.presenter; const amIModerator = currentUserData?.isModerator; @@ -68,6 +70,7 @@ const ActionsBarContainer = (props) => { actionBarItems, isThereCurrentPresentation, isSharingVideo, + stopExternalVideoShare, } } /> @@ -86,7 +89,6 @@ const isReactionsButtonEnabled = () => { }; export default withTracker(() => ({ - stopExternalVideoShare: ExternalVideoService.stopWatching, enableVideo: getFromUserSettings('bbb_enable_video', Meteor.settings.public.kurento.enableVideo), setPresentationIsOpen: MediaService.setPresentationIsOpen, isSharedNotesPinned: Service.isSharedNotesPinned(), diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/screenshare/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/screenshare/component.jsx index ec210fe463..7820f1bcf0 100755 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/screenshare/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/screenshare/component.jsx @@ -5,6 +5,7 @@ import deviceInfo from '/imports/utils/deviceInfo'; import browserInfo from '/imports/utils/browserInfo'; import logger from '/imports/startup/client/logger'; import { notify } from '/imports/ui/services/notification'; +import { useMutation } from '@apollo/client'; import Styled from './styles'; import ScreenshareBridgeService from '/imports/api/screenshare/client/bridge/service'; import { @@ -14,6 +15,7 @@ import { import { SCREENSHARING_ERRORS } from '/imports/api/screenshare/client/bridge/errors'; import Button from '/imports/ui/components/common/button/component'; import { parsePayloads } from 'sdp-transform'; +import { EXTERNAL_VIDEO_STOP } from '../../external-video-player/mutations'; const { isMobile } = deviceInfo; const { isSafari, isTabletApp } = browserInfo; @@ -118,6 +120,8 @@ const ScreenshareButton = ({ amIPresenter, isMeteorConnected, }) => { + const [stopExternalVideoShare] = useMutation(EXTERNAL_VIDEO_STOP); + // This is the failure callback that will be passed to the /api/screenshare/kurento.js // script on the presenter's call const handleFailure = (error) => { @@ -189,7 +193,7 @@ const ScreenshareButton = ({ if (isSafari && !ScreenshareBridgeService.HAS_DISPLAY_MEDIA) { setScreenshareUnavailableModalIsOpen(true); } else { - shareScreen(amIPresenter, handleFailure); + shareScreen(stopExternalVideoShare, amIPresenter, handleFailure); } }} id={amIBroadcasting ? 'unshare-screen-button' : 'share-screen-button'} diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/modal/service.ts b/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/modal/service.ts index a980a5b3b8..13a9290557 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/modal/service.ts +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/modal/service.ts @@ -1,13 +1,8 @@ import ReactPlayer from 'react-player'; -import { makeCall } from '/imports/ui/services/api'; const YOUTUBE_SHORTS_REGEX = new RegExp(/^(?:https?:\/\/)?(?:www\.)?(youtube\.com\/shorts)\/.+$/); const PANOPTO_MATCH_URL = /https?:\/\/([^/]+\/Panopto)(\/Pages\/Viewer\.aspx\?id=)([-a-zA-Z0-9]+)/; -export const stopWatching = () => { - makeCall('stopWatchingExternalVideo'); -}; - export const isUrlValid = (url: string) => { if (YOUTUBE_SHORTS_REGEX.test(url)) { const shortsUrl = url.replace('shorts/', 'watch?v='); @@ -19,5 +14,5 @@ export const isUrlValid = (url: string) => { }; export default { - stopWatching, + isUrlValid, }; diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/mutations.tsx b/bigbluebutton-html5/imports/ui/components/external-video-player/mutations.tsx index 78d44fbc59..9a13f1d00f 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/mutations.tsx +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/mutations.tsx @@ -24,4 +24,10 @@ export const EXTERNAL_VIDEO_UPDATE = gql` } `; -export default { EXTERNAL_VIDEO_START, EXTERNAL_VIDEO_UPDATE }; +export const EXTERNAL_VIDEO_STOP = gql` + mutation ExternalVideoStop { + externalVideoStop + } +`; + +export default { EXTERNAL_VIDEO_START, EXTERNAL_VIDEO_UPDATE, EXTERNAL_VIDEO_STOP }; diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/service.js b/bigbluebutton-html5/imports/ui/components/external-video-player/service.js index 7d23437391..5f4dfbdf91 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/service.js +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/service.js @@ -1,7 +1,6 @@ import Auth from '/imports/ui/services/auth'; import { getStreamer } from '/imports/api/external-videos'; -import { makeCall } from '/imports/ui/services/api'; import ReactPlayer from 'react-player'; import Panopto from './custom-players/panopto'; @@ -18,10 +17,6 @@ const isUrlValid = (url) => { return /^https.*$/.test(url) && (ReactPlayer.canPlay(url) || Panopto.canPlay(url)); }; -const stopWatching = () => { - makeCall('stopWatchingExternalVideo'); -}; - const onMessage = (message, func) => { const streamer = getStreamer(Auth.meetingID); streamer.on(message, func); @@ -43,6 +38,5 @@ export { onMessage, removeAllListeners, isUrlValid, - stopWatching, getPlayingState, }; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.jsx index 263744f597..f7d8d8a254 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.jsx @@ -51,6 +51,7 @@ class NotesDropdown extends PureComponent { intl, amIPresenter, presentations, + stopExternalVideoShare, } = this.props; const { converterButtonDisabled } = this.state; @@ -85,7 +86,7 @@ class NotesDropdown extends PureComponent { dataTest: 'pinNotes', label: intl.formatMessage(intlMessages.pinNotes), onClick: () => { - Service.pinSharedNotes(); + Service.pinSharedNotes(stopExternalVideoShare); }, }, ); diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/container.jsx b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/container.jsx index e736b07c63..a401850e22 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/container.jsx @@ -1,11 +1,12 @@ import React from 'react'; import NotesDropdown from './component'; import { layoutSelect } from '/imports/ui/components/layout/context'; -import { useSubscription } from '@apollo/client'; +import { useSubscription, useMutation } from '@apollo/client'; import { PROCESSED_PRESENTATIONS_SUBSCRIPTION, } from '/imports/ui/components/whiteboard/queries'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import { EXTERNAL_VIDEO_STOP } from '../../external-video-player/mutations'; const NotesDropdownContainer = ({ ...props }) => { const { data: currentUserData } = useCurrentUser((user) => ({ @@ -17,7 +18,21 @@ const NotesDropdownContainer = ({ ...props }) => { const { data: presentationData } = useSubscription(PROCESSED_PRESENTATIONS_SUBSCRIPTION); const presentations = presentationData?.pres_presentation || []; - return ; + const [stopExternalVideoShare] = useMutation(EXTERNAL_VIDEO_STOP); + + return ( + + ); }; export default NotesDropdownContainer; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/service.js b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/service.js index afc7d7b1bb..6b3f9cd8b7 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/service.js +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/service.js @@ -58,5 +58,5 @@ async function convertAndUpload(presentations) { export default { convertAndUpload, - pinSharedNotes: () => NotesService.pinSharedNotes(true), + pinSharedNotes: (stopWatching) => NotesService.pinSharedNotes(true, stopWatching), }; diff --git a/bigbluebutton-html5/imports/ui/components/notes/service.js b/bigbluebutton-html5/imports/ui/components/notes/service.js index fd56687766..f8f32e7e28 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/service.js +++ b/bigbluebutton-html5/imports/ui/components/notes/service.js @@ -58,8 +58,8 @@ const toggleNotesPanel = (sidebarContentPanel, layoutContextDispatch) => { }); }; -const pinSharedNotes = (pinned) => { - PadsService.pinPad(NOTES_CONFIG.id, pinned); +const pinSharedNotes = (pinned, stopWatching) => { + PadsService.pinPad(NOTES_CONFIG.id, pinned, stopWatching); }; const isSharedNotesPinned = () => { diff --git a/bigbluebutton-html5/imports/ui/components/pads/service.js b/bigbluebutton-html5/imports/ui/components/pads/service.js index 6cf2fab99f..87b5eca4f8 100644 --- a/bigbluebutton-html5/imports/ui/components/pads/service.js +++ b/bigbluebutton-html5/imports/ui/components/pads/service.js @@ -3,9 +3,6 @@ import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads'; import { makeCall } from '/imports/ui/services/api'; import Auth from '/imports/ui/services/auth'; import Settings from '/imports/ui/services/settings'; -import { - stopWatching, -} from '/imports/ui/components/external-video-player/service'; import { screenshareHasEnded, isScreenBroadcasting, @@ -113,7 +110,7 @@ const getPinnedPad = () => { return pad; }; -const pinPad = (externalId, pinned) => { +const pinPad = (externalId, pinned, stopWatching) => { if (pinned) { // Stop external video sharing if it's running. stopWatching(); diff --git a/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx b/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx index 94af6b95a3..aa46dd43cb 100755 --- a/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx @@ -141,6 +141,7 @@ class ScreenshareComponent extends React.Component { layoutContextDispatch, toggleSwapLayout, pinSharedNotes, + stopExternalVideoShare, } = this.props; screenshareHasEnded(); window.removeEventListener('screensharePlayFailed', this.handlePlayElementFailed); @@ -173,7 +174,7 @@ class ScreenshareComponent extends React.Component { value: Session.get('presentationLastState'), }); - pinSharedNotes(Session.get('pinnedNotesLastState')); + pinSharedNotes(Session.get('pinnedNotesLastState'), stopExternalVideoShare); } clearMediaFlowingMonitor() { diff --git a/bigbluebutton-html5/imports/ui/components/screenshare/container.jsx b/bigbluebutton-html5/imports/ui/components/screenshare/container.jsx index 23831a7456..9d212b6cef 100755 --- a/bigbluebutton-html5/imports/ui/components/screenshare/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/screenshare/container.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { withTracker } from 'meteor/react-meteor-data'; +import { useMutation } from '@apollo/client'; import { getSharingContentType, getBroadcastContentType, @@ -16,6 +17,7 @@ import AudioService from '/imports/ui/components/audio/service'; import MediaService from '/imports/ui/components/media/service'; import { defineMessages } from 'react-intl'; import NotesService from '/imports/ui/components/notes/service'; +import { EXTERNAL_VIDEO_STOP } from '../external-video-player/mutations'; const screenshareIntlMessages = defineMessages({ // SCREENSHARE @@ -95,6 +97,7 @@ const ScreenshareContainer = (props) => { const { element } = fullscreen; const fullscreenElementId = 'Screenshare'; const fullscreenContext = (element === fullscreenElementId); + const [stopExternalVideoShare] = useMutation(EXTERNAL_VIDEO_STOP); const { isPresenter } = props; @@ -130,6 +133,7 @@ const ScreenshareContainer = (props) => { ...screenShare, fullscreenContext, fullscreenElementId, + stopExternalVideoShare, ...selectedInfo, } } diff --git a/bigbluebutton-html5/imports/ui/components/screenshare/service.js b/bigbluebutton-html5/imports/ui/components/screenshare/service.js index 8f8ae5b7b2..8d0ba3b977 100644 --- a/bigbluebutton-html5/imports/ui/components/screenshare/service.js +++ b/bigbluebutton-html5/imports/ui/components/screenshare/service.js @@ -3,7 +3,6 @@ import KurentoBridge from '/imports/api/screenshare/client/bridge'; import BridgeService from '/imports/api/screenshare/client/bridge/service'; import Settings from '/imports/ui/services/settings'; import logger from '/imports/startup/client/logger'; -import { stopWatching } from '/imports/ui/components/external-video-player/service'; import Meetings from '/imports/api/meetings'; import Auth from '/imports/ui/services/auth'; import AudioService from '/imports/ui/components/audio/service'; @@ -281,7 +280,7 @@ const screenshareHasStarted = (isPresenter, options = {}) => { } }; -const shareScreen = async (isPresenter, onFail, options = {}) => { +const shareScreen = async (stopWatching, isPresenter, onFail, options = {}) => { if (isCameraAsContentBroadcasting()) { screenshareHasEnded(); } diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx b/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx index 74cd63b593..6c90090887 100755 --- a/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx @@ -1,64 +1,71 @@ import React from 'react'; import { withTracker } from 'meteor/react-meteor-data'; import Service from './service'; +import { useMutation } from '@apollo/client'; import VideoPreview from './component'; import VideoService from '../video-provider/service'; import ScreenShareService from '/imports/ui/components/screenshare/service'; import logger from '/imports/startup/client/logger'; import { SCREENSHARING_ERRORS } from '/imports/api/screenshare/client/bridge/errors'; +import { EXTERNAL_VIDEO_STOP } from '../external-video-player/mutations'; const VideoPreviewContainer = (props) => ; -export default withTracker(({ setIsOpen, callbackToClose }) => ({ - startSharing: (deviceId) => { - callbackToClose(); - setIsOpen(false); - VideoService.joinVideo(deviceId); - }, - startSharingCameraAsContent: (deviceId) => { - callbackToClose(); - setIsOpen(false); - const handleFailure = (error) => { - const { - errorCode = SCREENSHARING_ERRORS.UNKNOWN_ERROR.errorCode, - errorMessage = error.message, - } = error; +export default withTracker(({ setIsOpen, callbackToClose }) => { + const [stopExternalVideoShare] = useMutation(EXTERNAL_VIDEO_STOP); - logger.error({ - logCode: 'camera_as_content_failed', - extraInfo: { errorCode, errorMessage }, - }, `Sharing camera as content failed: ${errorMessage} (code=${errorCode})`); + return { + startSharing: (deviceId) => { + callbackToClose(); + setIsOpen(false); + VideoService.joinVideo(deviceId); + }, + startSharingCameraAsContent: (deviceId) => { + callbackToClose(); + setIsOpen(false); + const handleFailure = (error) => { + const { + errorCode = SCREENSHARING_ERRORS.UNKNOWN_ERROR.errorCode, + errorMessage = error.message, + } = error; + logger.error({ + logCode: 'camera_as_content_failed', + extraInfo: { errorCode, errorMessage }, + }, `Sharing camera as content failed: ${errorMessage} (code=${errorCode})`); + + ScreenShareService.screenshareHasEnded(); + }; + ScreenShareService.shareScreen( + stopExternalVideoShare, + true, handleFailure, { stream: Service.getStream(deviceId)._mediaStream } + ); + ScreenShareService.setCameraAsContentDeviceId(deviceId); + }, + stopSharing: (deviceId) => { + callbackToClose(); + setIsOpen(false); + if (deviceId) { + const streamId = VideoService.getMyStreamId(deviceId); + if (streamId) VideoService.stopVideo(streamId); + } else { + VideoService.exitVideo(); + } + }, + stopSharingCameraAsContent: () => { + callbackToClose(); + setIsOpen(false); ScreenShareService.screenshareHasEnded(); - }; - ScreenShareService.shareScreen( - true, handleFailure, { stream: Service.getStream(deviceId)._mediaStream } - ); - ScreenShareService.setCameraAsContentDeviceId(deviceId); - }, - stopSharing: (deviceId) => { - callbackToClose(); - setIsOpen(false); - if (deviceId) { - const streamId = VideoService.getMyStreamId(deviceId); - if (streamId) VideoService.stopVideo(streamId); - } else { - VideoService.exitVideo(); - } - }, - stopSharingCameraAsContent: () => { - callbackToClose(); - setIsOpen(false); - ScreenShareService.screenshareHasEnded(); - }, - sharedDevices: VideoService.getSharedDevices(), - cameraAsContentDeviceId: ScreenShareService.getCameraAsContentDeviceId(), - isCamLocked: VideoService.isUserLocked(), - camCapReached: VideoService.hasCapReached(), - closeModal: () => { - callbackToClose(); - setIsOpen(false); - }, - webcamDeviceId: Service.webcamDeviceId(), - hasVideoStream: VideoService.hasVideoStream(), -}))(VideoPreviewContainer); + }, + sharedDevices: VideoService.getSharedDevices(), + cameraAsContentDeviceId: ScreenShareService.getCameraAsContentDeviceId(), + isCamLocked: VideoService.isUserLocked(), + camCapReached: VideoService.hasCapReached(), + closeModal: () => { + callbackToClose(); + setIsOpen(false); + }, + webcamDeviceId: Service.webcamDeviceId(), + hasVideoStream: VideoService.hasVideoStream(), + }; +})(VideoPreviewContainer); diff --git a/bigbluebutton-html5/server/main.js b/bigbluebutton-html5/server/main.js index a5c41244a6..602a09fa57 100755 --- a/bigbluebutton-html5/server/main.js +++ b/bigbluebutton-html5/server/main.js @@ -22,7 +22,6 @@ import '/imports/api/users-persistent-data/server'; import '/imports/api/connection-status/server'; import '/imports/api/timer/server'; import '/imports/api/audio-captions/server'; -import '/imports/api/external-videos/server'; import '/imports/api/pads/server'; import '/imports/api/local-settings/server'; import '/imports/api/voice-call-states/server'; From 9e5d678df4471ee7aa4a0c9d83b6c6e53fd7778b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Fri, 19 Jan 2024 15:47:10 -0300 Subject: [PATCH 0189/1039] fix stop external video on screenshare --- .../imports/ui/components/screenshare/service.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/screenshare/service.js b/bigbluebutton-html5/imports/ui/components/screenshare/service.js index 8d0ba3b977..a8f2788beb 100644 --- a/bigbluebutton-html5/imports/ui/components/screenshare/service.js +++ b/bigbluebutton-html5/imports/ui/components/screenshare/service.js @@ -3,7 +3,6 @@ import KurentoBridge from '/imports/api/screenshare/client/bridge'; import BridgeService from '/imports/api/screenshare/client/bridge/service'; import Settings from '/imports/ui/services/settings'; import logger from '/imports/startup/client/logger'; -import Meetings from '/imports/api/meetings'; import Auth from '/imports/ui/services/auth'; import AudioService from '/imports/ui/components/audio/service'; import { Meteor } from "meteor/meteor"; @@ -284,12 +283,6 @@ const shareScreen = async (stopWatching, isPresenter, onFail, options = {}) => { if (isCameraAsContentBroadcasting()) { screenshareHasEnded(); } - // stop external video share if running - const meeting = Meetings.findOne({ meetingId: Auth.meetingID }); - - if (meeting && meeting.externalVideoUrl) { - stopWatching(); - } try { let stream; @@ -318,6 +311,8 @@ const shareScreen = async (stopWatching, isPresenter, onFail, options = {}) => { // Close Shared Notes if open. NotesService.pinSharedNotes(false); + // stop external video share if running + stopWatching(); setSharingContentType(contentType); setIsSharing(true); From fcb048e1fefcaec0ab38a1e8d6519265257e87b0 Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Fri, 19 Jan 2024 15:30:33 -0500 Subject: [PATCH 0190/1039] chore(export-ann): bump axios etc --- bbb-export-annotations/package-lock.json | 60 +++++++++++++++--------- bbb-export-annotations/package.json | 6 +-- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/bbb-export-annotations/package-lock.json b/bbb-export-annotations/package-lock.json index ab6424497d..b78a6ad9f5 100644 --- a/bbb-export-annotations/package-lock.json +++ b/bbb-export-annotations/package-lock.json @@ -8,7 +8,7 @@ "name": "bbb-export-annotations", "version": "0.0.1", "dependencies": { - "axios": "^0.26.0", + "axios": "^1.6.5", "form-data": "^4.0.0", "perfect-freehand": "^1.0.16", "probe-image-size": "^7.2.3", @@ -21,8 +21,8 @@ "eslint-config-google": "^0.14.0" }, "engines": { - "node": ">=16", - "npm": ">=8.5" + "node": ">=18.16.0", + "npm": ">=9.5.0" } }, "node_modules/@eslint/eslintrc": { @@ -287,11 +287,13 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "node_modules/axios": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.0.tgz", - "integrity": "sha512-lKoGLMYtHvFrPVt3r+RBMp9nh34N0M8zEfCWqdWZx6phynIEhQqAdydpyBAAG211zlhX9Rgu08cOamy6XjE5Og==", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", + "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", "dependencies": { - "follow-redirects": "^1.14.8" + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" } }, "node_modules/balanced-match": { @@ -692,9 +694,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "funding": [ { "type": "individual", @@ -1067,6 +1069,11 @@ "stream-parser": "~0.3.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -1319,9 +1326,9 @@ } }, "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -1573,11 +1580,13 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "axios": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.0.tgz", - "integrity": "sha512-lKoGLMYtHvFrPVt3r+RBMp9nh34N0M8zEfCWqdWZx6phynIEhQqAdydpyBAAG211zlhX9Rgu08cOamy6XjE5Og==", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", + "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", "requires": { - "follow-redirects": "^1.14.8" + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" } }, "balanced-match": { @@ -1881,9 +1890,9 @@ "dev": true }, "follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==" + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==" }, "form-data": { "version": "4.0.0", @@ -2161,6 +2170,11 @@ "stream-parser": "~0.3.1" } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -2358,9 +2372,9 @@ } }, "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true }, "wrappy": { diff --git a/bbb-export-annotations/package.json b/bbb-export-annotations/package.json index b32bc07158..67a3bdb2f2 100644 --- a/bbb-export-annotations/package.json +++ b/bbb-export-annotations/package.json @@ -7,7 +7,7 @@ "lint:fix": "eslint --fix **/*.js" }, "dependencies": { - "axios": "^0.26.0", + "axios": "^1.6.5", "form-data": "^4.0.0", "perfect-freehand": "^1.0.16", "probe-image-size": "^7.2.3", @@ -20,7 +20,7 @@ "eslint-config-google": "^0.14.0" }, "engines": { - "node": "^18.16.0", - "npm": "^9.5.0" + "node": ">=18.16.0", + "npm": ">=9.5.0" } } From 6a887c442f96b3aff1a29f56d06043a99b4e611f Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Thu, 4 Jan 2024 16:08:44 -0500 Subject: [PATCH 0191/1039] fix: Bump spring-boot-starter-validation to 2.7.17 to match bbb-web --- bbb-common-web/build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbb-common-web/build.sbt b/bbb-common-web/build.sbt index e7d24da7b7..8d713533f9 100755 --- a/bbb-common-web/build.sbt +++ b/bbb-common-web/build.sbt @@ -103,7 +103,7 @@ homepage := Some(url("http://www.bigbluebutton.org")) libraryDependencies ++= Seq( "javax.validation" % "validation-api" % "2.0.1.Final", - "org.springframework.boot" % "spring-boot-starter-validation" % "2.7.12", + "org.springframework.boot" % "spring-boot-starter-validation" % "2.7.17", "org.springframework.data" % "spring-data-commons" % "2.7.6", "org.apache.httpcomponents" % "httpclient" % "4.5.13", "org.postgresql" % "postgresql" % "42.4.3", From 02f1fe686d031aec0c55f30ef106c0369ee9e590 Mon Sep 17 00:00:00 2001 From: Paul Trudel Date: Tue, 21 Nov 2023 16:26:39 +0000 Subject: [PATCH 0192/1039] Upgrade Grails to 6.1 --- bigbluebutton-web/build.gradle | 16 +++++++++------- bigbluebutton-web/gradle.properties | 6 +++--- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/bigbluebutton-web/build.gradle b/bigbluebutton-web/build.gradle index 38aa753703..778aeb1ab3 100755 --- a/bigbluebutton-web/build.gradle +++ b/bigbluebutton-web/build.gradle @@ -9,7 +9,7 @@ buildscript { dependencies { classpath "org.grails:grails-gradle-plugin:${grailsGradlePluginVersion}" classpath "org.grails.plugins:hibernate5:${gormVersion}" - classpath "com.bertramlabs.plugins:asset-pipeline-gradle:4.0.0" + classpath "com.bertramlabs.plugins:asset-pipeline-gradle:4.3.0" classpath "gradle.plugin.com.github.erdi.webdriver-binaries:webdriver-binaries-gradle-plugin:2.6" classpath "org.grails.plugins:views-gradle:2.1.1" classpath "org.grails.plugins:views-json:2.1.1" @@ -53,10 +53,10 @@ repositories { } dependencies { - runtimeOnly "com.bertramlabs.plugins:asset-pipeline-grails:4.0.0" + runtimeOnly "com.bertramlabs.plugins:asset-pipeline-grails:4.3.0" - implementation "org.springframework:spring-core:5.3.21" - implementation "org.springframework:spring-context:5.3.27" + implementation "org.springframework:spring-core:5.3.31" + implementation "org.springframework:spring-context:5.3.31" implementation "org.springframework.boot:spring-boot:${springVersion}" implementation "org.springframework.boot:spring-boot-starter-logging:${springVersion}" implementation "org.springframework.boot:spring-boot-autoconfigure:${springVersion}" @@ -65,7 +65,7 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-tomcat:${springVersion}" implementation "org.grails:grails-web-boot:5.2.5" - implementation "org.springframework:spring-webmvc:5.3.27" + implementation "org.springframework:spring-webmvc:5.3.31" implementation "org.grails:grails-logging" implementation "org.grails:grails-plugin-rest:5.2.5" @@ -79,7 +79,7 @@ dependencies { implementation "org.grails.plugins:views-json:2.1.1" implementation "org.grails.plugins:cache" implementation "org.apache.xmlbeans:xmlbeans:5.0.3" - implementation "org.grails:grails-gradle-plugin:5.1.4" + implementation "org.grails:grails-gradle-plugin:${grailsGradlePluginVersion}" implementation "org.grails.plugins:async" implementation "org.grails.plugins:scaffolding" implementation "org.grails.plugins:events" @@ -109,7 +109,7 @@ dependencies { //--- BigBlueButton Dependencies End console "org.grails:grails-console:5.2.0" profile "org.grails.profiles:web" - runtimeOnly "com.bertramlabs.plugins:asset-pipeline-grails:4.0.0" + runtimeOnly "com.bertramlabs.plugins:asset-pipeline-grails:4.3.0" testImplementation "org.grails:grails-gorm-testing-support" testImplementation "org.grails.plugins:geb" testImplementation "org.grails:grails-web-testing-support" @@ -127,6 +127,8 @@ configurations.implementation { exclude group: 'io.micronaut', module: 'micronaut-aop' exclude group: 'com.h2database', module: 'h2' exclude group: 'org.graalvm.sdk', module: 'graal-sdk' + exclude group: 'io.github.gradle-nexus', module: 'publish-plugin' + exclude group: 'org.grails', module: 'grails-shell' } configurations { diff --git a/bigbluebutton-web/gradle.properties b/bigbluebutton-web/gradle.properties index 8212141475..0baceadce8 100644 --- a/bigbluebutton-web/gradle.properties +++ b/bigbluebutton-web/gradle.properties @@ -1,7 +1,7 @@ -grailsVersion=5.3.3 +grailsVersion=6.1.0 gormVersion=7.3.1 -gradleWrapperVersion=7.3.1 -grailsGradlePluginVersion=5.0.0 +gradleWrapperVersion=7.6.3 +grailsGradlePluginVersion=6.1.0 groovyVersion=3.0.19 tomcatEmbedVersion=9.0.82 springVersion=2.7.17 \ No newline at end of file diff --git a/bigbluebutton-web/gradle/wrapper/gradle-wrapper.properties b/bigbluebutton-web/gradle/wrapper/gradle-wrapper.properties index fd636e6c98..3cb5fe56a3 100644 --- a/bigbluebutton-web/gradle/wrapper/gradle-wrapper.properties +++ b/bigbluebutton-web/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.1-bin.zip \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-bin.zip \ No newline at end of file From 7c10f49f4a11b4b45b37daaf2c49ff93312873a3 Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Wed, 10 Jan 2024 14:15:56 -0500 Subject: [PATCH 0193/1039] fix(sec): filter tags in presentation name --- .../src/main/java/org/bigbluebutton/api/util/ParamsUtil.java | 4 ++++ bigbluebutton-html5/imports/ui/components/chat/service.js | 3 ++- .../web/controllers/PresentationController.groovy | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/util/ParamsUtil.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/util/ParamsUtil.java index 6e6697ad23..3f5c07aad6 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/util/ParamsUtil.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/util/ParamsUtil.java @@ -21,6 +21,10 @@ public class ParamsUtil { return text.replaceAll("\\p{Cc}", "").trim(); } + public static String stripTags(String text) { + return text.replaceAll("<[^>]*>", ""); +} + public static String escapeHTMLTags(String value) { return StringEscapeUtils.escapeHtml4(value); } diff --git a/bigbluebutton-html5/imports/ui/components/chat/service.js b/bigbluebutton-html5/imports/ui/components/chat/service.js index 8c1836cebc..609607b9d7 100755 --- a/bigbluebutton-html5/imports/ui/components/chat/service.js +++ b/bigbluebutton-html5/imports/ui/components/chat/service.js @@ -291,13 +291,14 @@ const removePackagedClassAttribute = (classnames, attribute) => { }; const getExportedPresentationString = (fileURI, filename, intl, fileStateType) => { + const sanitizedFilename = stripTags(filename); const intlFileStateType = fileStateType === 'Original' ? intlMessages.original : intlMessages.withWhiteboardAnnotations; const href = `${APP.bbbWebBase}/${fileURI}`; const warningIcon = ''; const label = `${intl.formatMessage(intlMessages.download)}`; const notAccessibleWarning = `${warningIcon}`; const link = `${label} ${notAccessibleWarning}`; - const name = `${filename} (${intl.formatMessage(intlFileStateType)})`; + const name = `${sanitizedFilename} (${intl.formatMessage(intlFileStateType)})`; return `${name}
${link}`; }; diff --git a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/PresentationController.groovy b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/PresentationController.groovy index 84a799e40d..1a4696a9d5 100755 --- a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/PresentationController.groovy +++ b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/PresentationController.groovy @@ -30,6 +30,7 @@ import org.apache.commons.io.FilenameUtils; import org.bigbluebutton.web.services.PresentationService import org.bigbluebutton.presentation.UploadedPresentation import org.bigbluebutton.api.MeetingService; +import org.bigbluebutton.api.util.ParamsUtil; import org.bigbluebutton.api.Util; class PresentationController { @@ -164,6 +165,7 @@ class PresentationController { // Gets the name minus the path from a full fileName. // a/b/c.txt --> c.txt presFilename = FilenameUtils.getName(presOrigFilename) + presFilename = ParamsUtil.stripTags(presFilename) filenameExt = FilenameUtils.getExtension(presFilename) } else { log.warn "Upload failed. File Empty." From 554f4f2e2ac097c06c7fc00f1f8838ba70e308a6 Mon Sep 17 00:00:00 2001 From: GuiLeme Date: Thu, 9 Nov 2023 09:50:38 -0300 Subject: [PATCH 0194/1039] [GHSA-j42p-fh2w-24q6] - validate URL for external upload of presentation. --- .../api/service/ValidationService.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/service/ValidationService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/service/ValidationService.java index 7971c456c3..b26b367adb 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/service/ValidationService.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/service/ValidationService.java @@ -14,6 +14,9 @@ import javax.validation.Validation; import javax.validation.Validator; import javax.validation.ValidatorFactory; import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.*; @@ -76,6 +79,11 @@ public class ValidationService { if(request == null) { violations.put("validationError", "Request not recognized"); + } else if(params.containsKey("presentationUploadExternalUrl")) { + String urlToValidate = params.get("presentationUploadExternalUrl")[0]; + if(!this.isValidURL(urlToValidate)) { + violations.put("validationError", "Param 'presentationUploadExternalUrl' is not a valid URL"); + } } else { request.populateFromParamsMap(params); violations = performValidation(request); @@ -84,6 +92,15 @@ public class ValidationService { return violations; } + boolean isValidURL(String url) { + try { + new URL(url).toURI(); + return true; + } catch (MalformedURLException | URISyntaxException e) { + return false; + } + } + private Request initializeRequest(ApiCall apiCall, Map params, String queryString) { Request request = null; Checksum checksum; From 7e313ed92ae1b453409b879c9ecec0f219e4e1b4 Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Fri, 19 Jan 2024 16:10:40 -0500 Subject: [PATCH 0195/1039] [Snyk] Fix for 2 vulnerabilities (recording-imex) --- bbb-recording-imex/pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bbb-recording-imex/pom.xml b/bbb-recording-imex/pom.xml index d3818141a3..bf7809a5c0 100755 --- a/bbb-recording-imex/pom.xml +++ b/bbb-recording-imex/pom.xml @@ -75,7 +75,7 @@ ch.qos.logback logback-core - 1.2.11 + 1.4.14 org.slf4j @@ -85,7 +85,7 @@ ch.qos.logback logback-classic - 1.2.11 + 1.4.14 From 4d64464029ab86efeac79cc00c8f1e00edb0c6ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Sat, 20 Jan 2024 11:01:20 -0300 Subject: [PATCH 0196/1039] migrate switchSlide action --- .../imports/api/slides/server/index.js | 1 - .../imports/api/slides/server/methods.js | 6 -- .../api/slides/server/methods/switchSlide.js | 30 -------- .../ui/components/presentation/mutations.jsx | 10 +++ .../presentation-toolbar/component.jsx | 20 ++---- .../presentation-toolbar/container.jsx | 40 +++++++++-- .../presentation-toolbar/service.js | 25 ------- .../ui/components/whiteboard/component.jsx | 70 +++++++++---------- .../ui/components/whiteboard/container.jsx | 6 -- .../ui/components/whiteboard/service.js | 5 -- bigbluebutton-html5/server/main.js | 1 - 11 files changed, 84 insertions(+), 130 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/slides/server/index.js delete mode 100644 bigbluebutton-html5/imports/api/slides/server/methods.js delete mode 100755 bigbluebutton-html5/imports/api/slides/server/methods/switchSlide.js delete mode 100755 bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/service.js diff --git a/bigbluebutton-html5/imports/api/slides/server/index.js b/bigbluebutton-html5/imports/api/slides/server/index.js deleted file mode 100644 index ba55c4a16e..0000000000 --- a/bigbluebutton-html5/imports/api/slides/server/index.js +++ /dev/null @@ -1 +0,0 @@ -import './methods'; diff --git a/bigbluebutton-html5/imports/api/slides/server/methods.js b/bigbluebutton-html5/imports/api/slides/server/methods.js deleted file mode 100644 index 211d15fe65..0000000000 --- a/bigbluebutton-html5/imports/api/slides/server/methods.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import switchSlide from './methods/switchSlide'; - -Meteor.methods({ - switchSlide, -}); diff --git a/bigbluebutton-html5/imports/api/slides/server/methods/switchSlide.js b/bigbluebutton-html5/imports/api/slides/server/methods/switchSlide.js deleted file mode 100755 index 76d4bdd2b0..0000000000 --- a/bigbluebutton-html5/imports/api/slides/server/methods/switchSlide.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; -import RedisPubSub from '/imports/startup/server/redis'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import Logger from '/imports/startup/server/logger'; - -export default async function switchSlide(slideNumber, podId, presentationId) { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'SetCurrentPagePubMsg'; - - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(slideNumber, Number); - check(podId, String); - - const payload = { - podId, - presentationId, - pageId: `${presentationId}/${slideNumber}`, - }; - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method switchSlide ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx index 39b4d4490a..9afed2f79f 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx @@ -23,7 +23,17 @@ export const PRESENTATION_SET_WRITERS = gql` } `; +export const PRESENTATION_SET_PAGE = gql` + mutation PresentationSetPage($presentationId: String!, $pageId: String!) { + presentationSetPage( + presentationId: $presentationId, + pageId: $pageId, + ) + } +`; + export default { PRESENTATION_SET_ZOOM, PRESENTATION_SET_WRITERS, + PRESENTATION_SET_PAGE, }; diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/component.jsx index 8748bdf4b2..06333f46b8 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/component.jsx @@ -150,12 +150,12 @@ class PresentationToolbar extends PureComponent { } handleSkipToSlideChange(event) { - const { skipToSlide, presentationId } = this.props; + const { skipToSlide } = this.props; const requestedSlideNum = Number.parseInt(event.target.value, 10); this.handleFTWSlideChange(); if (event) event.currentTarget.blur(); - skipToSlide(requestedSlideNum, presentationId); + skipToSlide(requestedSlideNum); } handleSwitchWhiteboardMode() { @@ -194,29 +194,21 @@ class PresentationToolbar extends PureComponent { } nextSlideHandler(event) { - const { - nextSlide, - currentSlideNum, - numberOfSlides, - endCurrentPoll, - presentationId, - } = this.props; + const { nextSlide, endCurrentPoll } = this.props; this.handleFTWSlideChange(); if (event) event.currentTarget.blur(); endCurrentPoll(); - nextSlide(currentSlideNum, numberOfSlides, presentationId); + nextSlide(); } previousSlideHandler(event) { - const { - previousSlide, currentSlideNum, endCurrentPoll, presentationId - } = this.props; + const { previousSlide, endCurrentPoll } = this.props; this.handleFTWSlideChange(); if (event) event.currentTarget.blur(); endCurrentPoll(); - previousSlide(currentSlideNum, presentationId); + previousSlide(); } switchSlide(event) { diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/container.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/container.jsx index bb9eb4af95..041e55cb8f 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/container.jsx @@ -2,19 +2,24 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { withTracker } from 'meteor/react-meteor-data'; import PresentationToolbar from './component'; -import PresentationToolbarService from './service'; import FullscreenService from '/imports/ui/components/common/fullscreen-button/service'; import { isPollingEnabled } from '/imports/ui/services/features'; import { PluginsContext } from '/imports/ui/components/components-data/plugin-context/context'; import { useSubscription, useMutation } from '@apollo/client'; import POLL_SUBSCRIPTION from '/imports/ui/core/graphql/queries/pollSubscription'; import { POLL_CANCEL, POLL_CREATE } from '/imports/ui/components/poll/mutations'; +import { PRESENTATION_SET_PAGE } from '../mutations'; const PresentationToolbarContainer = (props) => { const pluginsContext = useContext(PluginsContext); const { pluginsExtensibleAreasAggregatedState } = pluginsContext; - const { userIsPresenter, layoutSwapped } = props; + const { + userIsPresenter, + layoutSwapped, + currentSlideNum, + presentationId, + } = props; const { data: pollData } = useSubscription(POLL_SUBSCRIPTION); const hasPoll = pollData?.poll?.length > 0; @@ -23,11 +28,36 @@ const PresentationToolbarContainer = (props) => { const [stopPoll] = useMutation(POLL_CANCEL); const [createPoll] = useMutation(POLL_CREATE); + const [presentationSetPage] = useMutation(PRESENTATION_SET_PAGE); const endCurrentPoll = () => { if (hasPoll) stopPoll(); }; + const setPresentationPage = (pageId) => { + presentationSetPage({ + variables: { + presentationId, + pageId, + }, + }); + }; + + const skipToSlide = (slideNum) => { + const slideId = `${presentationId}/${slideNum}`; + setPresentationPage(slideId); + }; + + const previousSlide = () => { + const prevSlideNum = currentSlideNum - 1; + skipToSlide(prevSlideNum); + }; + + const nextSlide = () => { + const nextSlideNum = currentSlideNum + 1; + skipToSlide(nextSlideNum); + }; + const startPoll = (pollType, pollId, answers = [], question, isMultipleResponse = false) => { Session.set('openPanel', 'poll'); Session.set('forcePollOpen', true); @@ -60,6 +90,9 @@ const PresentationToolbarContainer = (props) => { pluginProvidedPresentationToolbarItems, handleToggleFullScreen, startPoll, + previousSlide, + nextSlide, + skipToSlide, }} /> ); @@ -69,9 +102,6 @@ const PresentationToolbarContainer = (props) => { export default withTracker(() => { return { - nextSlide: PresentationToolbarService.nextSlide, - previousSlide: PresentationToolbarService.previousSlide, - skipToSlide: PresentationToolbarService.skipToSlide, isMeteorConnected: Meteor.status().connected, isPollingEnabled: isPollingEnabled(), }; diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/service.js b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/service.js deleted file mode 100755 index 2c843f4eee..0000000000 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/service.js +++ /dev/null @@ -1,25 +0,0 @@ -import { makeCall } from '/imports/ui/services/api'; - -const POD_ID = 'DEFAULT_PRESENTATION_POD'; - -const previousSlide = (currentSlideNum, presentationId) => { - if (currentSlideNum > 1) { - makeCall('switchSlide', currentSlideNum - 1, POD_ID, presentationId); - } -}; - -const nextSlide = (currentSlideNum, numberOfSlides, presentationId) => { - if (currentSlideNum < numberOfSlides) { - makeCall('switchSlide', currentSlideNum + 1, POD_ID, presentationId); - } -}; - -const skipToSlide = (requestedSlideNum, presentationId) => { - makeCall('switchSlide', requestedSlideNum, POD_ID, presentationId); -}; - -export default { - nextSlide, - previousSlide, - skipToSlide, -}; diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx index 80b473b137..9cddcda5c7 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx @@ -106,7 +106,6 @@ export default Whiteboard = React.memo(function Whiteboard(props) { currentUser, whiteboardId, zoomSlide, - skipToSlide, curPageId, zoomChanger, isMultiUserActive, @@ -117,11 +116,11 @@ export default Whiteboard = React.memo(function Whiteboard(props) { svgUri, maxStickyNoteLength, fontFamily, - colorStyle, - dashStyle, - fillStyle, - fontStyle, - sizeStyle, + colorStyle, + dashStyle, + fillStyle, + fontStyle, + sizeStyle, hasShapeAccess, presentationAreaHeight, presentationAreaWidth, @@ -670,29 +669,29 @@ export default Whiteboard = React.memo(function Whiteboard(props) { console.log("EDITOR : ", editor); const debouncePersistShape = debounce({ delay: 0 }, persistShape); - - const colorStyles = ['black', 'blue', 'green', 'grey', 'light-blue', 'light-green', 'light-red', 'light-violet', 'orange', 'red', 'violet', 'yellow']; - const dashStyles = ['dashed', 'dotted', 'draw', 'solid']; - const fillStyles = ['none', 'pattern', 'semi', 'solid']; - const fontStyles = ['draw','mono','sans', 'serif']; - const sizeStyles = ['l', 'm', 's', 'xl']; - - if ( colorStyles.includes(colorStyle) ) { - editor.setStyleForNextShapes(DefaultColorStyle, colorStyle); - } - if ( dashStyles.includes(dashStyle) ) { - editor.setStyleForNextShapes(DefaultDashStyle, dashStyle); - } - if ( fillStyles.includes(fillStyle) ) { - editor.setStyleForNextShapes(DefaultFillStyle, fillStyle); - } - if ( fontStyles.includes(fontStyle)) { - editor.setStyleForNextShapes(DefaultFontStyle, fontStyle); - } - if ( sizeStyles.includes(sizeStyle) ) { - editor.setStyleForNextShapes(DefaultSizeStyle, sizeStyle); - } - + + const colorStyles = ['black', 'blue', 'green', 'grey', 'light-blue', 'light-green', 'light-red', 'light-violet', 'orange', 'red', 'violet', 'yellow']; + const dashStyles = ['dashed', 'dotted', 'draw', 'solid']; + const fillStyles = ['none', 'pattern', 'semi', 'solid']; + const fontStyles = ['draw','mono','sans', 'serif']; + const sizeStyles = ['l', 'm', 's', 'xl']; + + if ( colorStyles.includes(colorStyle) ) { + editor.setStyleForNextShapes(DefaultColorStyle, colorStyle); + } + if ( dashStyles.includes(dashStyle) ) { + editor.setStyleForNextShapes(DefaultDashStyle, dashStyle); + } + if ( fillStyles.includes(fillStyle) ) { + editor.setStyleForNextShapes(DefaultFillStyle, fillStyle); + } + if ( fontStyles.includes(fontStyle)) { + editor.setStyleForNextShapes(DefaultFontStyle, fontStyle); + } + if ( sizeStyles.includes(sizeStyle) ) { + editor.setStyleForNextShapes(DefaultSizeStyle, sizeStyle); + } + editor.store.listen( (entry) => { const { changes } = entry; @@ -900,7 +899,6 @@ Whiteboard.propTypes = { }).isRequired, whiteboardId: PropTypes.string, zoomSlide: PropTypes.func.isRequired, - skipToSlide: PropTypes.func.isRequired, curPageId: PropTypes.string.isRequired, presentationWidth: PropTypes.number.isRequired, presentationHeight: PropTypes.number.isRequired, @@ -914,11 +912,11 @@ Whiteboard.propTypes = { svgUri: PropTypes.string, maxStickyNoteLength: PropTypes.number.isRequired, fontFamily: PropTypes.string.isRequired, - colorStyle: PropTypes.string.isRequired, - dashStyle: PropTypes.string.isRequired, - fillStyle: PropTypes.string.isRequired, - fontStyle: PropTypes.string.isRequired, - sizeStyle: PropTypes.string.isRequired, + colorStyle: PropTypes.string.isRequired, + dashStyle: PropTypes.string.isRequired, + fillStyle: PropTypes.string.isRequired, + fontStyle: PropTypes.string.isRequired, + sizeStyle: PropTypes.string.isRequired, hasShapeAccess: PropTypes.func.isRequired, presentationAreaHeight: PropTypes.number.isRequired, presentationAreaWidth: PropTypes.number.isRequired, @@ -934,9 +932,7 @@ Whiteboard.propTypes = { fullscreenAction: PropTypes.string.isRequired, fullscreenRef: PropTypes.instanceOf(Element), handleToggleFullScreen: PropTypes.func.isRequired, - nextSlide: PropTypes.func.isRequired, numberOfSlides: PropTypes.number.isRequired, - previousSlide: PropTypes.func.isRequired, sidebarNavigationWidth: PropTypes.number, presentationId: PropTypes.string, }; diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx index 3cdb96951c..2266f8507b 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx @@ -11,14 +11,12 @@ import { initDefaultPages, persistShape, removeShapes, - changeCurrentSlide, notifyNotAllowedChange, notifyShapeNumberExceeded, toggleToolsAnimations, formatAnnotations, } from './service'; import CursorService from './cursors/service'; -import PresentationToolbarService from '../presentation/presentation-toolbar/service'; import SettingsService from '/imports/ui/services/settings'; import Auth from '/imports/ui/services/auth'; import { @@ -235,15 +233,11 @@ const WhiteboardContainer = (props) => { initDefaultPages, persistShape, isMultiUserActive, - changeCurrentSlide, shapes, bgShape, assets, removeShapes, zoomSlide, - skipToSlide: PresentationToolbarService.skipToSlide, - nextSlide: PresentationToolbarService.nextSlide, - previousSlide: PresentationToolbarService.previousSlide, numberOfSlides: currentPresentationPage?.totalPages, notifyNotAllowedChange, notifyShapeNumberExceeded, diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/service.js b/bigbluebutton-html5/imports/ui/components/whiteboard/service.js index 2c91fec606..6043a7f426 100755 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/service.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/service.js @@ -100,10 +100,6 @@ const persistShape = (shape, whiteboardId, isModerator) => { const removeShapes = (shapes, whiteboardId) => makeCall('deleteAnnotations', shapes, whiteboardId); -const changeCurrentSlide = (s) => { - makeCall('changeCurrentSlide', s); -}; - const initDefaultPages = (count = 1) => { const pages = {}; const pageStates = {}; @@ -284,7 +280,6 @@ export { getMultiUser, persistShape, removeShapes, - changeCurrentSlide, notifyNotAllowedChange, notifyShapeNumberExceeded, toggleToolsAnimations, diff --git a/bigbluebutton-html5/server/main.js b/bigbluebutton-html5/server/main.js index a5c41244a6..739d7f3c09 100755 --- a/bigbluebutton-html5/server/main.js +++ b/bigbluebutton-html5/server/main.js @@ -9,7 +9,6 @@ import '/imports/api/polls/server'; import '/imports/api/captions/server'; import '/imports/api/presentations/server'; import '/imports/api/presentation-upload-token/server'; -import '/imports/api/slides/server'; import '/imports/api/breakouts/server'; import '/imports/api/breakouts-history/server'; import '/imports/api/screenshare/server'; From 0319fb7d27a3bc52f75d2a6dc0768a64955ef348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Sat, 20 Jan 2024 11:43:34 -0300 Subject: [PATCH 0197/1039] migrate exportPresentation action --- .../api/presentations/server/methods.js | 2 -- .../server/methods/exportPresentation.js | 29 ------------------- .../ui/components/presentation/mutations.jsx | 14 +++++++++ .../presentation-uploader/container.jsx | 14 +++++++-- 4 files changed, 25 insertions(+), 34 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/presentations/server/methods/exportPresentation.js diff --git a/bigbluebutton-html5/imports/api/presentations/server/methods.js b/bigbluebutton-html5/imports/api/presentations/server/methods.js index f02acab509..ab965e9787 100644 --- a/bigbluebutton-html5/imports/api/presentations/server/methods.js +++ b/bigbluebutton-html5/imports/api/presentations/server/methods.js @@ -2,11 +2,9 @@ import { Meteor } from 'meteor/meteor'; import removePresentation from './methods/removePresentation'; import setPresentation from './methods/setPresentation'; import setPresentationDownloadable from './methods/setPresentationDownloadable'; -import exportPresentation from './methods/exportPresentation'; Meteor.methods({ removePresentation, setPresentation, setPresentationDownloadable, - exportPresentation, }); diff --git a/bigbluebutton-html5/imports/api/presentations/server/methods/exportPresentation.js b/bigbluebutton-html5/imports/api/presentations/server/methods/exportPresentation.js deleted file mode 100644 index 0eb8ce9cfa..0000000000 --- a/bigbluebutton-html5/imports/api/presentations/server/methods/exportPresentation.js +++ /dev/null @@ -1,29 +0,0 @@ -import RedisPubSub from '/imports/startup/server/redis'; -import { check } from 'meteor/check'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import Logger from '/imports/startup/server/logger'; - -export default async function exportPresentation(presentationId, fileStateType) { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'MakePresentationDownloadReqMsg'; - - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(presentationId, String); - - const payload = { - presId: presentationId, - allPages: true, - fileStateType, - pages: [], - }; - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method exportPresentation ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx index 9afed2f79f..78a5a95549 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx @@ -32,8 +32,22 @@ export const PRESENTATION_SET_PAGE = gql` } `; +export const PRESENTATION_SET_DOWNLOADABLE = gql` + mutation PresentationSetDownloadable( + $presentationId: String!, + $downloadable: Boolean!, + $fileStateType: String!,) { + presentationSetDownloadable( + presentationId: $presentationId, + downloadable: $downloadable, + fileStateType: $fileStateType, + ) + } +`; + export default { PRESENTATION_SET_ZOOM, PRESENTATION_SET_WRITERS, PRESENTATION_SET_PAGE, + PRESENTATION_SET_DOWNLOADABLE, }; diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx index cbc9adb22e..d2ca8dbcd6 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx @@ -1,9 +1,9 @@ import React from 'react'; import { Meteor } from 'meteor/meteor'; import { withTracker } from 'meteor/react-meteor-data'; -import { makeCall } from '/imports/ui/services/api'; import ErrorBoundary from '/imports/ui/components/common/error-boundary/component'; import FallbackModal from '/imports/ui/components/common/fallback-errors/fallback-modal/component'; +import { useSubscription, useMutation } from '@apollo/client'; import Service from './service'; import PresUploaderToast from '/imports/ui/components/presentation/presentation-toast/presentation-uploader-toast/component'; import PresentationUploader from './component'; @@ -13,11 +13,11 @@ import { isDownloadPresentationConvertedToPdfEnabled, isPresentationEnabled, } from '/imports/ui/services/features'; -import { useSubscription } from '@apollo/client'; import { PRESENTATIONS_SUBSCRIPTION, } from '/imports/ui/components/whiteboard/queries'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import { PRESENTATION_SET_DOWNLOADABLE } from '../mutations'; const PRESENTATION_CONFIG = Meteor.settings.public.presentation; @@ -31,8 +31,16 @@ const PresentationUploaderContainer = (props) => { const presentations = presentationData?.pres_presentation || []; const currentPresentation = presentations.find((p) => p.current)?.presentationId || ''; + const [presentationSetDownloadable] = useMutation(PRESENTATION_SET_DOWNLOADABLE); + const exportPresentation = (presentationId, fileStateType) => { - makeCall('exportPresentation', presentationId, fileStateType); + presentationSetDownloadable({ + variables: { + presentationId, + downloadable: true, + fileStateType, + }, + }); }; return userIsPresenter && ( From c3b3d6c3901c02b3f46b38d1742115b78fccbdc2 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Sat, 20 Jan 2024 13:41:14 -0300 Subject: [PATCH 0198/1039] Stop logging as Error when it's a normal WS conn closing --- .../internal/hascli/conn/reader/reader.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go index 486f76b641..f7daf1691a 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go +++ b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go @@ -1,6 +1,8 @@ package reader import ( + "context" + "errors" "github.com/iMDT/bbb-graphql-middleware/internal/common" "github.com/iMDT/bbb-graphql-middleware/internal/hascli/retransmiter" "github.com/iMDT/bbb-graphql-middleware/internal/msgpatch" @@ -23,7 +25,11 @@ func HasuraConnectionReader(hc *common.HasuraConnection, fromHasuraToBrowserChan var message interface{} err := wsjson.Read(hc.Context, hc.Websocket, &message) if err != nil { - log.Errorf("Error: %v", err) + if errors.Is(err, context.Canceled) { + log.Debugf("Closing ws connection as Context was cancelled!") + } else { + log.Errorf("Error reading message from Hasura: %v", err) + } return } @@ -59,6 +65,7 @@ func HasuraConnectionReader(hc *common.HasuraConnection, fromHasuraToBrowserChan subscription.Type == common.Subscription { msgpatch.PatchMessage(&messageAsMap, hc.Browserconn) } + } // Write the message to browser From 38132045e28035056771bc9b8ee4f6a70932e203 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Sat, 20 Jan 2024 13:42:24 -0300 Subject: [PATCH 0199/1039] Make middleware store last cursor for streaming subscriptions (to improve reconnection) --- .../internal/common/StreamCursorUtils.go | 128 ++++++++++++++++++ .../internal/common/types.go | 4 + .../internal/hascli/conn/reader/reader.go | 12 ++ .../internal/hascli/conn/writer/writer.go | 9 ++ .../hascli/retransmiter/retransmiter.go | 8 +- 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 bbb-graphql-middleware/internal/common/StreamCursorUtils.go diff --git a/bbb-graphql-middleware/internal/common/StreamCursorUtils.go b/bbb-graphql-middleware/internal/common/StreamCursorUtils.go new file mode 100644 index 0000000000..88353307a8 --- /dev/null +++ b/bbb-graphql-middleware/internal/common/StreamCursorUtils.go @@ -0,0 +1,128 @@ +package common + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +func GetStreamCursorPropsFromQuery(payload map[string]interface{}, query string) (string, string, interface{}) { + streamCursorKey := "" + streamCursorVariable := "" + var streamCursorInitialValue interface{} + + regexPattern := `cursor:\s*\{\s*initial_value\s*:\s*\{\s*([^\:]+):\s*([^}]+)\s*\}\s*\}` + re := regexp.MustCompile(regexPattern) + matches := re.FindStringSubmatch(query) + if matches != nil { + streamCursorKey = matches[1] + if strings.HasPrefix(matches[2], "$") { + //Variable + streamCursorVariable, _ = strings.CutPrefix(matches[2], "$") + variables, ok := payload["variables"].(map[string]interface{}) + if ok { + for varKey, varValue := range variables { + if varKey == streamCursorVariable { + streamCursorInitialValue = varValue + } + } + } + } else { + streamCursorInitialValue = matches[2] + } + } + + return streamCursorKey, streamCursorVariable, streamCursorInitialValue +} + +func GetLastStreamCursorValueFromReceivedMessage(messageAsMap map[string]interface{}, streamCursorKey string) interface{} { + var lastStreamCursorValue interface{} + + if payload, okPayload := messageAsMap["payload"].(map[string]interface{}); okPayload { + if data, okData := payload["data"].(map[string]interface{}); okData { + //Data will have only one prop, `range` because its name is unknown + for _, dataItem := range data { + currentDataProp, okCurrentDataProp := dataItem.([]interface{}) + if okCurrentDataProp && len(currentDataProp) > 0 { + // Get the last item directly (once it will contain the last cursor value) + lastItemOfMessage := currentDataProp[len(currentDataProp)-1] + if lastItemOfMessageAsMap, currDataOk := lastItemOfMessage.(map[string]interface{}); currDataOk { + if lastItemValue, okLastItemValue := lastItemOfMessageAsMap[streamCursorKey]; okLastItemValue { + lastStreamCursorValue = lastItemValue + //fmt.Println("Descobriu ultimo valor: " + lastStreamCursorValue.(string)) + } + } + } + } + } + } + + return lastStreamCursorValue +} + +func ReplaceMessageWithLastCursorValue(subscription GraphQlSubscription) interface{} { + var message = subscription.Message.(map[string]interface{}) + payload, okPayload := message["payload"].(map[string]interface{}) + + if okPayload { + if subscription.StreamCursorVariable != "" { + /**** This stream has its cursor value set through variables ****/ + if variables, okVariables := payload["variables"].(map[string]interface{}); okVariables { + if variables[subscription.StreamCursorVariable] != subscription.StreamCursorCurrValue { + variables[subscription.StreamCursorVariable] = subscription.StreamCursorCurrValue + payload["variables"] = variables + message["payload"] = payload + } + } + } else { + /**** This stream has its cursor value set through inline value (not variables) ****/ + query, okQuery := payload["query"].(string) + if okQuery { + pattern := `cursor:\s*\{\s*initial_value\s*:\s*\{\s*([^\:]+:\s*[^}]+)\s*\}\s*\}` + re := regexp.MustCompile(pattern) + + newValue := "" + + replaceInitialValueFunc := func(match string) string { + switch v := subscription.StreamCursorCurrValue.(type) { + case string: + newValue = v + + //Append quotes if it is missing + if !strings.HasPrefix(v, "\"") { + newValue = "\"" + newValue + } + if !strings.HasSuffix(v, "\"") { + newValue = newValue + "\"" + } + case int: + newValue = strconv.Itoa(v) + case float32: + myFloat64 := float64(v) + newValue = strconv.FormatFloat(myFloat64, 'f', -1, 32) + case float64: + newValue = strconv.FormatFloat(v, 'f', -1, 64) + default: + newValue = "" + } + + if newValue != "" { + replacement := subscription.StreamCursorKey + ": " + newValue + return fmt.Sprintf("cursor: {initial_value: {%s}}", replacement) + } else { + return match + } + } + + newQuery := re.ReplaceAllStringFunc(query, replaceInitialValueFunc) + if query != newQuery { + payload["query"] = newQuery + message["payload"] = payload + } + } + } + } + + return message +} diff --git a/bbb-graphql-middleware/internal/common/types.go b/bbb-graphql-middleware/internal/common/types.go index 666ccc8932..93ee15affe 100644 --- a/bbb-graphql-middleware/internal/common/types.go +++ b/bbb-graphql-middleware/internal/common/types.go @@ -21,6 +21,10 @@ type GraphQlSubscription struct { Id string Message interface{} Type QueryType + OperationName string + StreamCursorKey string + StreamCursorVariable string + StreamCursorCurrValue interface{} JsonPatchSupported bool // indicate if client support Json Patch for this subscription LastSeenOnHasuraConnetion string // id of the hasura connection that this query was active } diff --git a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go index f7daf1691a..0c4c213c83 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go +++ b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go @@ -66,6 +66,18 @@ func HasuraConnectionReader(hc *common.HasuraConnection, fromHasuraToBrowserChan msgpatch.PatchMessage(&messageAsMap, hc.Browserconn) } + //Set last cursor value for stream + if subscription.Type == common.Streaming { + lastCursor := common.GetLastStreamCursorValueFromReceivedMessage(messageAsMap, subscription.StreamCursorKey) + if lastCursor != nil && subscription.StreamCursorCurrValue != lastCursor { + subscription.StreamCursorCurrValue = lastCursor + + hc.Browserconn.ActiveSubscriptionsMutex.Lock() + hc.Browserconn.ActiveSubscriptions[queryId] = subscription + hc.Browserconn.ActiveSubscriptionsMutex.Unlock() + } + + } } // Write the message to browser diff --git a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go index 8b34d03609..3f10f51e60 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go +++ b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go @@ -41,7 +41,11 @@ RangeLoop: //Identify type based on query string messageType := common.Query + streamCursorKey := "" + streamCursorVariable := "" + var streamCursorInitialValue interface{} payload := fromBrowserMessageAsMap["payload"].(map[string]interface{}) + query, ok := payload["query"].(string) if ok { if strings.HasPrefix(query, "subscription") { @@ -49,6 +53,7 @@ RangeLoop: if strings.Contains(query, "_stream(") && strings.Contains(query, "cursor: {") { messageType = common.Streaming + streamCursorKey, streamCursorVariable, streamCursorInitialValue = common.GetStreamCursorPropsFromQuery(payload, query) } if strings.Contains(query, "_aggregate") && strings.Contains(query, "aggregate {") { @@ -73,6 +78,10 @@ RangeLoop: browserConnection.ActiveSubscriptions[queryId] = common.GraphQlSubscription{ Id: queryId, Message: fromBrowserMessage, + OperationName: operationName, + StreamCursorKey: streamCursorKey, + StreamCursorVariable: streamCursorVariable, + StreamCursorCurrValue: streamCursorInitialValue, LastSeenOnHasuraConnetion: hc.Id, JsonPatchSupported: jsonPatchSupported, Type: messageType, diff --git a/bbb-graphql-middleware/internal/hascli/retransmiter/retransmiter.go b/bbb-graphql-middleware/internal/hascli/retransmiter/retransmiter.go index 5cb1bb7cd9..30afeb2c77 100644 --- a/bbb-graphql-middleware/internal/hascli/retransmiter/retransmiter.go +++ b/bbb-graphql-middleware/internal/hascli/retransmiter/retransmiter.go @@ -11,8 +11,14 @@ func RetransmitSubscriptionStartMessages(hc *common.HasuraConnection, fromBrowse hc.Browserconn.ActiveSubscriptionsMutex.RLock() for _, subscription := range hc.Browserconn.ActiveSubscriptions { if subscription.LastSeenOnHasuraConnetion != hc.Id { + log.Tracef("retransmiting subscription start: %v", subscription.Message) - fromBrowserToHasuraChannel.Send(subscription.Message) + + if subscription.Type == common.Streaming && subscription.StreamCursorCurrValue != nil { + fromBrowserToHasuraChannel.Send(common.ReplaceMessageWithLastCursorValue(subscription)) + } else { + fromBrowserToHasuraChannel.Send(subscription.Message) + } } } hc.Browserconn.ActiveSubscriptionsMutex.RUnlock() From 9de2c484be29fe7b709f798bb173971d07f607fb Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Sat, 20 Jan 2024 13:56:46 -0300 Subject: [PATCH 0200/1039] Code cleansing --- .../internal/common/StreamCursorUtils.go | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/bbb-graphql-middleware/internal/common/StreamCursorUtils.go b/bbb-graphql-middleware/internal/common/StreamCursorUtils.go index 88353307a8..76f9341503 100644 --- a/bbb-graphql-middleware/internal/common/StreamCursorUtils.go +++ b/bbb-graphql-middleware/internal/common/StreamCursorUtils.go @@ -12,20 +12,15 @@ func GetStreamCursorPropsFromQuery(payload map[string]interface{}, query string) streamCursorVariable := "" var streamCursorInitialValue interface{} - regexPattern := `cursor:\s*\{\s*initial_value\s*:\s*\{\s*([^\:]+):\s*([^}]+)\s*\}\s*\}` - re := regexp.MustCompile(regexPattern) - matches := re.FindStringSubmatch(query) + cursorInitialValueRePattern := regexp.MustCompile(`cursor:\s*\{\s*initial_value\s*:\s*\{\s*([^:]+):\s*([^}]+)\s*}\s*}`) + matches := cursorInitialValueRePattern.FindStringSubmatch(query) if matches != nil { streamCursorKey = matches[1] if strings.HasPrefix(matches[2], "$") { - //Variable streamCursorVariable, _ = strings.CutPrefix(matches[2], "$") - variables, ok := payload["variables"].(map[string]interface{}) - if ok { - for varKey, varValue := range variables { - if varKey == streamCursorVariable { - streamCursorInitialValue = varValue - } + if variables, okVariables := payload["variables"].(map[string]interface{}); okVariables { + if targetVariableValue, okTargetVariableValue := variables[streamCursorVariable]; okTargetVariableValue { + streamCursorInitialValue = targetVariableValue } } } else { @@ -50,7 +45,6 @@ func GetLastStreamCursorValueFromReceivedMessage(messageAsMap map[string]interfa if lastItemOfMessageAsMap, currDataOk := lastItemOfMessage.(map[string]interface{}); currDataOk { if lastItemValue, okLastItemValue := lastItemOfMessageAsMap[streamCursorKey]; okLastItemValue { lastStreamCursorValue = lastItemValue - //fmt.Println("Descobriu ultimo valor: " + lastStreamCursorValue.(string)) } } } @@ -79,9 +73,7 @@ func ReplaceMessageWithLastCursorValue(subscription GraphQlSubscription) interfa /**** This stream has its cursor value set through inline value (not variables) ****/ query, okQuery := payload["query"].(string) if okQuery { - pattern := `cursor:\s*\{\s*initial_value\s*:\s*\{\s*([^\:]+:\s*[^}]+)\s*\}\s*\}` - re := regexp.MustCompile(pattern) - + cursorInitialValueRePattern := regexp.MustCompile(`cursor:\s*\{\s*initial_value\s*:\s*\{\s*([^:]+:\s*[^}]+)\s*}\s*}`) newValue := "" replaceInitialValueFunc := func(match string) string { @@ -89,7 +81,7 @@ func ReplaceMessageWithLastCursorValue(subscription GraphQlSubscription) interfa case string: newValue = v - //Append quotes if it is missing + //Append quotes if it is missing, it will be necessary when appending to the query if !strings.HasPrefix(v, "\"") { newValue = "\"" + newValue } @@ -115,7 +107,7 @@ func ReplaceMessageWithLastCursorValue(subscription GraphQlSubscription) interfa } } - newQuery := re.ReplaceAllStringFunc(query, replaceInitialValueFunc) + newQuery := cursorInitialValueRePattern.ReplaceAllStringFunc(query, replaceInitialValueFunc) if query != newQuery { payload["query"] = newQuery message["payload"] = payload From f01c55680e375be39eaaeb54f87cb28d0fa52f55 Mon Sep 17 00:00:00 2001 From: KDSBrowne Date: Mon, 22 Jan 2024 17:15:41 +0000 Subject: [PATCH 0201/1039] add checks for prevShape existence and remoteShape id in shape sync --- .../imports/ui/components/whiteboard/component.jsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx index 2113565227..e8d2e23e90 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx @@ -222,8 +222,9 @@ export default Whiteboard = React.memo(function Whiteboard(props) { }); Object.values(prevShapesRef.current).forEach((remoteShape) => { + if (!remoteShape.id) return; const localShape = localLookup.get(remoteShape.id); - + const prevShape = prevShapesRef.current[remoteShape.id]; // Create a deep clone of remoteShape and remove the isModerator property const comparisonRemoteShape = deepCloneUsingShallow(remoteShape); delete comparisonRemoteShape.isModerator; @@ -234,7 +235,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) { // If the shape does not exist in local, add it to toAdd toAdd.push(remoteShape); } - } else if (!isEqual(localShape, comparisonRemoteShape)) { + } else if (!isEqual(localShape, comparisonRemoteShape) && prevShape) { // Capture the differences const diff = { id: remoteShape.id, @@ -242,7 +243,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) { typeName: remoteShape.typeName, }; - if (!selectedShapeIds.includes(diff?.id) && prevShapesRef.current[`${diff?.id}`].meta?.updatedBy !== currentUser?.userId) { + if (!selectedShapeIds.includes(remoteShape.id) && prevShape?.meta?.updatedBy !== currentUser?.userId) { // Compare each property Object.keys(remoteShape).forEach((key) => { if ( From 22b74ed18c728e39b2df459a86036de36d454209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Mon, 22 Jan 2024 14:47:46 -0300 Subject: [PATCH 0202/1039] migrate setPresentationDownloadable action --- .../api/presentations/server/methods.js | 2 -- .../methods/setPresentationDownloadable.js | 31 ------------------- .../presentation-uploader/container.jsx | 13 ++++++-- .../presentation-uploader/service.js | 5 --- 4 files changed, 11 insertions(+), 40 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/presentations/server/methods/setPresentationDownloadable.js diff --git a/bigbluebutton-html5/imports/api/presentations/server/methods.js b/bigbluebutton-html5/imports/api/presentations/server/methods.js index ab965e9787..f772ab0b5b 100644 --- a/bigbluebutton-html5/imports/api/presentations/server/methods.js +++ b/bigbluebutton-html5/imports/api/presentations/server/methods.js @@ -1,10 +1,8 @@ import { Meteor } from 'meteor/meteor'; import removePresentation from './methods/removePresentation'; import setPresentation from './methods/setPresentation'; -import setPresentationDownloadable from './methods/setPresentationDownloadable'; Meteor.methods({ removePresentation, setPresentation, - setPresentationDownloadable, }); diff --git a/bigbluebutton-html5/imports/api/presentations/server/methods/setPresentationDownloadable.js b/bigbluebutton-html5/imports/api/presentations/server/methods/setPresentationDownloadable.js deleted file mode 100644 index 1ad147ca9c..0000000000 --- a/bigbluebutton-html5/imports/api/presentations/server/methods/setPresentationDownloadable.js +++ /dev/null @@ -1,31 +0,0 @@ -import RedisPubSub from '/imports/startup/server/redis'; -import { check } from 'meteor/check'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import Logger from '/imports/startup/server/logger'; - -export default function setPresentationDownloadable(presentationId, downloadable, fileStateType) { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'SetPresentationDownloadablePubMsg'; - - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(downloadable, Match.Maybe(Boolean)); - check(presentationId, String); - check(fileStateType, Match.Maybe(String)); - - const payload = { - presentationId, - podId: 'DEFAULT_PRESENTATION_POD', - downloadable, - fileStateType, - }; - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method setPresentationDownloadable ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx index d2ca8dbcd6..1364e528aa 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx @@ -43,6 +43,16 @@ const PresentationUploaderContainer = (props) => { }); }; + const dispatchChangePresentationDownloadable = (presentationId, downloadable, fileStateType) => { + presentationSetDownloadable({ + variables: { + presentationId, + downloadable, + fileStateType, + }, + }); + }; + return userIsPresenter && ( { presentations={presentations} currentPresentation={currentPresentation} exportPresentation={exportPresentation} + dispatchChangePresentationDownloadable={dispatchChangePresentationDownloadable} {...props} /> @@ -60,7 +71,6 @@ export default withTracker(() => { const { dispatchDisableDownloadable, dispatchEnableDownloadable, - dispatchChangePresentationDownloadable, } = Service; const isOpen = isPresentationEnabled() && (Session.get('showUploadPresentationView') || false); @@ -78,7 +88,6 @@ export default withTracker(() => { renderPresentationItemStatus: PresUploaderToast.renderPresentationItemStatus, dispatchDisableDownloadable, dispatchEnableDownloadable, - dispatchChangePresentationDownloadable, isOpen, selectedToBeNextCurrent: Session.get('selectedToBeNextCurrent') || null, externalUploadData: Service.getExternalUploadData(), diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js index ac1188285a..2719e2c52c 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js @@ -41,10 +41,6 @@ const futch = (url, opts = {}, onProgress) => new Promise((res, rej) => { xhr.send(opts.body); }); -const dispatchChangePresentationDownloadable = (presentation, newState, fileStateType) => { - makeCall('setPresentationDownloadable', presentation.presentationId, newState, fileStateType); -}; - const requestPresentationUploadToken = ( temporaryPresentationId, meetingId, @@ -352,7 +348,6 @@ function handleFiledrop(files, files2, that, intl, intlMessages) { export default { handleSavePresentation, persistPresentationChanges, - dispatchChangePresentationDownloadable, setPresentation, requestPresentationUploadToken, getExternalUploadData, From 1757c0b2f1ea11f20fc77bd4ac894d4e7e543d32 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Mon, 22 Jan 2024 15:51:33 -0300 Subject: [PATCH 0203/1039] Add a flag to inform if current user responded to the Poll --- bbb-graphql-server/bbb_schema.sql | 7 +++++++ .../BigBlueButton/tables/public_v_poll.yaml | 10 ++++++++++ .../tables/public_v_poll_user_current.yaml | 17 +++++++++++++++++ .../databases/BigBlueButton/tables/tables.yaml | 1 + 4 files changed, 35 insertions(+) create mode 100644 bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_poll_user_current.yaml diff --git a/bbb-graphql-server/bbb_schema.sql b/bbb-graphql-server/bbb_schema.sql index 0450e87f8f..9e59dc9520 100644 --- a/bbb-graphql-server/bbb_schema.sql +++ b/bbb-graphql-server/bbb_schema.sql @@ -1310,6 +1310,13 @@ FROM poll_option o JOIN poll using("pollId") WHERE poll."type" != 'R-'; +create view "v_poll_user_current" as +select "user"."userId", "poll"."pollId", case when count(pr.*) > 0 then true else false end as responded +from "user" +join "poll" on "poll"."meetingId" = "user"."meetingId" +left join "poll_response" pr on pr."userId" = "user"."userId" and pr."pollId" = "poll"."pollId" +group by "user"."userId", "poll"."pollId"; + -------------------------------- ----External video diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_poll.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_poll.yaml index e6c689b5ba..15d9468584 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_poll.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_poll.yaml @@ -6,6 +6,16 @@ configuration: custom_column_names: {} custom_name: poll custom_root_fields: {} +object_relationships: + - name: userCurrent + using: + manual_configuration: + column_mapping: + pollId: pollId + insertion_order: null + remote_table: + name: v_poll_user_current + schema: public array_relationships: - name: options using: diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_poll_user_current.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_poll_user_current.yaml new file mode 100644 index 0000000000..9be764473a --- /dev/null +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_poll_user_current.yaml @@ -0,0 +1,17 @@ +table: + name: v_poll_user_current + schema: public +configuration: + column_config: {} + custom_column_names: {} + custom_name: pollUserCurrent + custom_root_fields: {} +select_permissions: + - role: bbb_client + permission: + columns: + - responded + filter: + userId: + _eq: X-Hasura-UserId + comment: "" diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/tables.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/tables.yaml index 27edd90911..6a0321df91 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/tables.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/tables.yaml @@ -26,6 +26,7 @@ - "!include public_v_poll_option.yaml" - "!include public_v_poll_response.yaml" - "!include public_v_poll_user.yaml" +- "!include public_v_poll_user_current.yaml" - "!include public_v_pres_annotation_curr.yaml" - "!include public_v_pres_annotation_history_curr.yaml" - "!include public_v_pres_page.yaml" From d33544ef1ec437e8ae085556229132519ac253d8 Mon Sep 17 00:00:00 2001 From: Anton B Date: Mon, 22 Jan 2024 16:10:47 -0300 Subject: [PATCH 0204/1039] fix: use service file for meteor data consumption, useRef instead of let variable, add error log --- .../component.tsx | 27 +++++++++---------- .../meeting-remaining-time-graphql/service.ts | 7 +++++ 2 files changed, 20 insertions(+), 14 deletions(-) create mode 100644 bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/service.ts diff --git a/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/component.tsx index 32b21d4188..8e6b8e39d3 100644 --- a/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/component.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import humanizeSeconds from '/imports/utils/humanizeSeconds'; -import BreakoutService from '/imports/ui/components/breakout-room/service'; +import { setCapturedContentUploading } from './service'; import { Text, Time } from './styles'; import useMeeting from '/imports/ui/core/hooks/useMeeting'; import { useSubscription } from '@apollo/client'; @@ -9,6 +9,7 @@ import { FIRST_BREAKOUT_DURATION_DATA_SUBSCRIPTION, breakoutDataResponse } from import { notify } from '/imports/ui/services/notification'; import { Meteor } from 'meteor/meteor'; import useTimeSync from '/imports/ui/core/local-states/useTimeSync'; +import logger from '/imports/startup/client/logger'; const intlMessages = defineMessages({ breakoutTimeRemaining: { @@ -51,12 +52,9 @@ interface MeetingRemainingTimeContainerProps { displayAlerts: boolean; } -interface MeetingRemainingTimeProps { +interface MeetingRemainingTimeProps extends MeetingRemainingTimeContainerProps { durationInSeconds: number; referenceStartedTime: number; - isBreakoutDuration: boolean; - fromBreakoutPanel: boolean | false; - displayAlerts: boolean; isBreakout: boolean | false; } @@ -110,8 +108,8 @@ const MeetingRemainingTime: React.FC = (props) => { }; }, [remainingTime, durationInSeconds]); - let meetingTimeMessage: string = ''; - let boldText: boolean = false; + const meetingTimeMessage = React.useRef(''); + const boldText = React.useRef(false); if (remainingTime >= 0 && timeRemainingInterval) { if (remainingTime > 0) { @@ -142,21 +140,21 @@ const MeetingRemainingTime: React.FC = (props) => { ); } - if (fromBreakoutPanel) boldText = true; - meetingTimeMessage = intl.formatMessage(fromBreakoutPanel || isBreakoutDuration + if (fromBreakoutPanel) boldText.current = true; + meetingTimeMessage.current = intl.formatMessage(fromBreakoutPanel || isBreakoutDuration ? intlMessages.breakoutDuration : intlMessages.meetingTimeRemaining, { 0: humanizeSeconds(remainingTime) }); } else { clearInterval(timeRemainingInterval.current); - BreakoutService.setCapturedContentUploading(); - meetingTimeMessage = intl.formatMessage(isBreakoutDuration + setCapturedContentUploading(); + meetingTimeMessage.current = intl.formatMessage(isBreakoutDuration ? intlMessages.breakoutWillClose : intlMessages.meetingWillClose); } } - if (boldText) { - const words = meetingTimeMessage.split(' '); + if (boldText.current) { + const words = meetingTimeMessage.current.split(' '); const time = words.pop(); const text = words.join(' '); @@ -171,7 +169,7 @@ const MeetingRemainingTime: React.FC = (props) => { return ( - {meetingTimeMessage} + {meetingTimeMessage.current} ); }; @@ -200,6 +198,7 @@ const MeetingRemainingTimeContainer: React.FC Error: diff --git a/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/service.ts b/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/service.ts new file mode 100644 index 0000000000..bb63556602 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/service.ts @@ -0,0 +1,7 @@ +import BreakoutService from '/imports/ui/components/breakout-room/service'; + +export const setCapturedContentUploading = () => BreakoutService.setCapturedContentUploading(); + +export default { + setCapturedContentUploading, +}; From d69bbe95287d09ec359b23e53b5d30e78de239a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Thu, 11 Jan 2024 13:51:51 -0300 Subject: [PATCH 0205/1039] Refactor: migrate poll answer gathering --- .../ui/components/polling/container.jsx | 5 +- .../polling/polling-graphql/component.tsx | 401 ++++++++++++++++++ .../polling/polling-graphql/queries.ts | 52 +++ .../polling/polling-graphql/service.ts | 5 + .../polling/polling-graphql/styles.ts | 244 +++++++++++ 5 files changed, 706 insertions(+), 1 deletion(-) create mode 100644 bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/polling/polling-graphql/queries.ts create mode 100644 bigbluebutton-html5/imports/ui/components/polling/polling-graphql/service.ts create mode 100644 bigbluebutton-html5/imports/ui/components/polling/polling-graphql/styles.ts diff --git a/bigbluebutton-html5/imports/ui/components/polling/container.jsx b/bigbluebutton-html5/imports/ui/components/polling/container.jsx index 43fb81a4f6..fbec196ade 100644 --- a/bigbluebutton-html5/imports/ui/components/polling/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/polling/container.jsx @@ -8,6 +8,7 @@ import PollingComponent from './component'; import { isPollingEnabled } from '/imports/ui/services/features'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; import { POLL_SUBMIT_TYPED_VOTE, POLL_SUBMIT_VOTE } from '/imports/ui/components/poll/mutations'; +import PollingGraphqlContainer from './polling-graphql/component'; const propTypes = { pollExists: PropTypes.bool.isRequired, @@ -50,7 +51,7 @@ const PollingContainer = ({ pollExists, ...props }) => { PollingContainer.propTypes = propTypes; -export default withTracker(() => { +withTracker(() => { const { pollExists, poll, } = PollingService.mapPolls(); @@ -70,3 +71,5 @@ export default withTracker(() => { isMeteorConnected: Meteor.status().connected, }); })(PollingContainer); + +export default PollingGraphqlContainer; diff --git a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx new file mode 100644 index 0000000000..a26e9bc313 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx @@ -0,0 +1,401 @@ +import React, { + ElementRef, useEffect, useMemo, useRef, useState, +} from 'react'; +import { useMutation, useSubscription } from '@apollo/client'; +import { defineMessages, useIntl } from 'react-intl'; +import { Meteor } from 'meteor/meteor'; +import AudioService from '/imports/ui/components/audio/service'; +import Checkbox from '/imports/ui/components/common/checkbox/component'; +import PollService from '/imports/ui/components/poll/service'; +import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import { isPollingEnabled } from '/imports/ui/services/features'; +import { + POLL_SUBMIT_TYPED_VOTE, + POLL_SUBMIT_VOTE, +} from '/imports/ui/components/poll/mutations'; +import { + hasPendingPoll, + HasPendingPollResponse, +} from './queries'; +import { shouldStackOptions } from './service'; +import Styled from './styles'; + +const MAX_INPUT_CHARS = Meteor.settings.public.poll.maxTypedAnswerLength; + +const intlMessages = defineMessages({ + pollingTitleLabel: { + id: 'app.polling.pollingTitle', + }, + pollAnswerLabel: { + id: 'app.polling.pollAnswerLabel', + }, + pollAnswerDesc: { + id: 'app.polling.pollAnswerDesc', + }, + pollQuestionTitle: { + id: 'app.polling.pollQuestionTitle', + }, + responseIsSecret: { + id: 'app.polling.responseSecret', + }, + responseNotSecret: { + id: 'app.polling.responseNotSecret', + }, + submitLabel: { + id: 'app.polling.submitLabel', + }, + submitAriaLabel: { + id: 'app.polling.submitAriaLabel', + }, + responsePlaceholder: { + id: 'app.polling.responsePlaceholder', + }, +}); + +const validateInput = (i: string) => { + let input = i; + if (/^\s/.test(input)) input = ''; + return input; +}; + +interface PollingGraphqlContainerProps {} + +interface PollingGraphqlProps { + handleTypedVote: (pollId: string, answer: string) => void; + handleVote: (pollId: string, answerIds: Array) => void; + pollAnswerIds: Record; + pollTypes: Record; + isDefaultPoll: (pollType: string) => boolean; + poll: { + pollId: string; + multipleResponses: boolean; + type: string; + stackOptions: boolean; + questionText: string; + secret: boolean; + options: Array<{ + optionDesc: string; + optionId: number; + pollId: string; + }>; + }; +} + +const PollingGraphql: React.FC = (props) => { + const { + handleTypedVote, + handleVote, + poll, + pollAnswerIds, + pollTypes, + isDefaultPoll, + } = props; + + const [typedAns, setTypedAns] = useState(''); + const [checkedAnswers, setCheckedAnswers] = useState>([]); + const intl = useIntl(); + const responseInput = useRef>(null); + const pollingContainer = useRef>(null); + + useEffect(() => { + play(); + if (pollingContainer.current) { + pollingContainer.current.focus(); + } + }, []); + + const play = () => { + AudioService.playAlertSound( + `${ + Meteor.settings.public.app.cdn + + Meteor.settings.public.app.basename + + Meteor.settings.public.app.instanceId + }/resources/sounds/Poll.mp3`, + ); + }; + + const handleUpdateResponseInput = (e: React.ChangeEvent) => { + if (responseInput.current) { + responseInput.current.value = validateInput(e.target.value); + setTypedAns(responseInput.current.value); + } + }; + + const handleSubmit = (pollId: string) => { + handleVote(pollId, checkedAnswers); + }; + + const handleCheckboxChange = (answerId: number) => { + if (checkedAnswers.includes(answerId)) { + checkedAnswers.splice(checkedAnswers.indexOf(answerId), 1); + } else { + checkedAnswers.push(answerId); + } + checkedAnswers.sort(); + setCheckedAnswers([...checkedAnswers]); + }; + + const handleMessageKeyDown = (e: React.KeyboardEvent) => { + if (e.keyCode === 13 && typedAns.length > 0) { + handleTypedVote(poll.pollId, typedAns); + } + }; + + const renderButtonAnswers = () => { + const { + stackOptions, + options, + questionText, + type, + } = poll; + const defaultPoll = isDefaultPoll(type); + + return ( +
+ {poll.type !== pollTypes.Response && ( + + {questionText.length === 0 && ( + + {intl.formatMessage(intlMessages.pollingTitleLabel)} + + )} + + {options.map((option) => { + const formattedMessageIndex = option.optionDesc.toLowerCase(); + let label = option.optionDesc; + if ( + (defaultPoll || type.includes('CUSTOM')) + && pollAnswerIds[formattedMessageIndex] + ) { + label = intl.formatMessage( + pollAnswerIds[formattedMessageIndex], + ); + } + + return ( + + handleVote(poll.pollId, [option.optionId])} + aria-labelledby={`pollAnswerLabel${option.optionDesc}`} + aria-describedby={`pollAnswerDesc${option.optionDesc}`} + data-test="pollAnswerOption" + /> + + {intl.formatMessage(intlMessages.pollAnswerLabel, { + 0: label, + })} + + + {intl.formatMessage(intlMessages.pollAnswerDesc, { + 0: label, + })} + + + ); + })} + + + )} + {poll.type === pollTypes.Response && ( + + { + handleUpdateResponseInput(e); + }} + onKeyDown={(e) => { + handleMessageKeyDown(e); + }} + type="text" + placeholder={intl.formatMessage(intlMessages.responsePlaceholder)} + maxLength={MAX_INPUT_CHARS} + ref={responseInput} + onPaste={(e) => { + e.stopPropagation(); + }} + onCut={(e) => { + e.stopPropagation(); + }} + onCopy={(e) => { + e.stopPropagation(); + }} + /> + { + handleTypedVote(poll.pollId, typedAns); + }} + /> + + )} + + {intl.formatMessage( + poll.secret + ? intlMessages.responseIsSecret + : intlMessages.responseNotSecret, + )} + +
+ ); + }; + + const renderCheckboxAnswers = () => { + return ( +
+ {poll.questionText.length === 0 && ( + + {intl.formatMessage(intlMessages.pollingTitleLabel)} + + )} + + {poll.options.map((option) => { + const formattedMessageIndex = option.optionDesc.toLowerCase(); + let label = option.optionDesc; + if (pollAnswerIds[formattedMessageIndex]) { + label = intl.formatMessage(pollAnswerIds[formattedMessageIndex]); + } + + return ( + +
+ + + + {intl.formatMessage(intlMessages.pollAnswerDesc, { + 0: label, + })} + + + + ); + })} + +
+ handleSubmit(poll.pollId)} + data-test="submitAnswersMultiple" + /> +
+ + ); + }; + + return ( + + + {poll.questionText.length > 0 && ( + + + {intl.formatMessage(intlMessages.pollQuestionTitle)} + + + {poll.questionText} + + + )} + {poll.multipleResponses + ? renderCheckboxAnswers() + : renderButtonAnswers()} + + + ); +}; + +const PollingGraphqlContainer: React.FC = () => { + const { data: currentUserData } = useCurrentUser((u) => ({ + userId: u.userId, + presenter: u.presenter, + })); + const { data: hasPendingPollData, error, loading } = useSubscription( + hasPendingPoll, + { + variables: { userId: currentUserData?.userId }, + }, + ); + const [pollSubmitUserTypedVote] = useMutation(POLL_SUBMIT_TYPED_VOTE); + const [pollSubmitUserVote] = useMutation(POLL_SUBMIT_VOTE); + + const meetingData = hasPendingPollData && hasPendingPollData.meeting[0]; + const pollData = meetingData && meetingData.polls[0]; + const userData = pollData && pollData.users[0]; + const pollExists = !!userData; + const showPolling = pollExists && !currentUserData?.presenter && isPollingEnabled(); + const stackOptions = useMemo( + () => !!pollData && shouldStackOptions(pollData.options.map((o) => o.optionDesc)), + [pollData], + ); + + const handleTypedVote = (pollId: string, answer: string) => { + pollSubmitUserTypedVote({ + variables: { + pollId, + answer, + }, + }); + }; + + const handleVote = (pollId: string, answerIds: Array) => { + pollSubmitUserVote({ + variables: { + pollId, + answerIds, + }, + }); + }; + + if (!showPolling || error || loading) return null; + + return ( + + ); +}; + +export default PollingGraphqlContainer; diff --git a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/queries.ts b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/queries.ts new file mode 100644 index 0000000000..6dd88f4bbd --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/queries.ts @@ -0,0 +1,52 @@ +import { gql } from '@apollo/client'; + +export interface HasPendingPollResponse { + meeting: Array<{ + polls: Array<{ + users: Array<{ + userId: string; + responded: boolean; + }>; + options: Array<{ + optionDesc: string; + optionId: number; + pollId: string; + }>; + multipleResponses: boolean; + pollId: string; + questionText: string; + secret: boolean; + type: string; + }>; + }>; +} + +export const hasPendingPoll = gql` + subscription hasPendingPoll($userId: String!) { + meeting { + polls( + where: { + ended: { _eq: false } + users: { responded: { _eq: false }, userId: { _eq: $userId } } + } + ) { + users { + responded + userId + } + options { + optionDesc + optionId + pollId + } + multipleResponses + pollId + questionText + secret + type + } + } + } +`; + +export default { hasPendingPoll }; diff --git a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/service.ts b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/service.ts new file mode 100644 index 0000000000..848179b395 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/service.ts @@ -0,0 +1,5 @@ +const MAX_CHAR_LENGTH = 5; + +export const shouldStackOptions = (keys: Array) => keys.some((k) => k.length > MAX_CHAR_LENGTH); + +export default { shouldStackOptions }; diff --git a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/styles.ts b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/styles.ts new file mode 100644 index 0000000000..52fbffb7cb --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/styles.ts @@ -0,0 +1,244 @@ +import styled from 'styled-components'; +import { + mdPaddingY, + smPaddingY, + jumboPaddingY, + smPaddingX, + borderRadius, + pollWidth, + pollSmMargin, + overlayIndex, + overlayOpacity, + pollIndex, + lgPaddingY, + pollBottomOffset, + jumboPaddingX, + pollColAmount, + borderSize, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + fontSizeSmall, + fontSizeBase, + fontSizeLarge, +} from '/imports/ui/stylesheets/styled-components/typography'; +import { + colorText, + colorBlueLight, + colorGrayLighter, + colorOffWhite, + colorGrayDark, + colorWhite, + colorPrimary, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { hasPhoneDimentions } from '/imports/ui/stylesheets/styled-components/breakpoints'; +import Button from '/imports/ui/components/common/button/component'; + +const PollingTitle = styled.div` + white-space: nowrap; + padding-bottom: ${mdPaddingY}; + padding-top: ${mdPaddingY}; + font-size: ${fontSizeSmall}; +`; + +const PollButtonWrapper = styled.div` + text-align: center; + padding: ${smPaddingY}; + width: 100%; +`; + +// @ts-ignore Until everything in Typescript +const PollingButton = styled(Button)` + width: 100%; + max-width: 9em; + + @media ${hasPhoneDimentions} { + max-width: none; + } + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const Hidden = styled.div` + display: none; +`; + +const TypedResponseWrapper = styled.div` + margin: ${jumboPaddingY} 0.5rem 0.5rem 0.5rem; + display: flex; + flex-flow: column; +`; + +const TypedResponseInput = styled.input` + &:focus { + outline: none; + border-radius: ${borderSize}; + box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, + inset 0 0 0 1px ${colorPrimary}; + } + + color: ${colorText}; + -webkit-appearance: none; + padding: calc(${smPaddingY} * 2.5) calc(${smPaddingX} * 1.25); + border-radius: ${borderRadius}; + font-size: ${fontSizeBase}; + border: 1px solid ${colorGrayLighter}; + box-shadow: 0 0 0 1px ${colorGrayLighter}; + margin-bottom: 1rem; +`; + +// @ts-ignore Until everything in Typescript +const SubmitVoteButton = styled(Button)` + font-size: ${fontSizeBase}; +`; + +const PollingSecret = styled.div` + font-size: ${fontSizeSmall}; + max-width: ${pollWidth}; +`; + +const MultipleResponseAnswersTable = styled.table` + margin-left: auto; + margin-right: auto; +`; + +const PollingCheckbox = styled.div` + display: inline-block; + margin-right: ${pollSmMargin}; +`; + +const CheckboxContainer = styled.tr` + margin-bottom: ${pollSmMargin}; +`; + +const MultipleResponseAnswersTableAnswerText = styled.td` + text-align: left; +`; + +const Overlay = styled.div` + position: absolute; + height: 100vh; + width: 100vw; + z-index: ${overlayIndex}; + pointer-events: none; + + @media ${hasPhoneDimentions} { + pointer-events: auto; + background-color: rgba(0, 0, 0, ${overlayOpacity}); + } +`; + +const QHeader = styled.span` + text-align: left; + position: relative; + left: ${smPaddingY}; +`; + +const QTitle = styled.div` + font-size: ${fontSizeSmall}; +`; + +const QText = styled.div` + color: ${colorText}; + word-break: break-word; + white-space: pre-wrap; + font-size: ${fontSizeLarge}; + max-width: ${pollWidth}; + padding-right: ${smPaddingX}; +`; + +const PollingContainer = styled.aside<{ autoWidth: boolean }>` + pointer-events: auto; + min-width: ${pollWidth}; + position: absolute; + + z-index: ${pollIndex}; + border: 1px solid ${colorOffWhite}; + border-radius: ${borderRadius}; + box-shadow: ${colorGrayDark} 0px 0px ${lgPaddingY}; + align-items: center; + text-align: center; + font-weight: 600; + padding: ${mdPaddingY}; + background-color: ${colorWhite}; + bottom: ${pollBottomOffset}; + right: ${jumboPaddingX}; + + &:focus { + border: 1px solid ${colorPrimary}; + } + + [dir="rtl"] & { + left: ${jumboPaddingX}; + right: auto; + } + + @media ${hasPhoneDimentions} { + bottom: auto; + right: auto; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-height: 95%; + overflow-y: auto; + + [dir="rtl"] & { + left: 50%; + } + } + + ${({ autoWidth }) => autoWidth + && ` + width: auto; + `} +`; + +const PollingAnswers = styled.div<{ removeColumns: boolean; stacked: boolean }>` + display: grid; + grid-template-columns: repeat(${pollColAmount}, 1fr); + + @media ${hasPhoneDimentions} { + grid-template-columns: repeat(1, 1fr); + + & div button { + grid-column: 1; + } + } + + z-index: 1; + + ${({ removeColumns }) => removeColumns + && ` + grid-template-columns: auto; + `} + + ${({ stacked }) => stacked + && ` + grid-template-columns: repeat(1, 1fr); + + & div button { + max-width: none !important; + } + `} +`; + +export default { + PollingTitle, + PollButtonWrapper, + PollingButton, + Hidden, + TypedResponseWrapper, + TypedResponseInput, + SubmitVoteButton, + PollingSecret, + MultipleResponseAnswersTable, + PollingCheckbox, + CheckboxContainer, + MultipleResponseAnswersTableAnswerText, + Overlay, + QHeader, + QTitle, + QText, + PollingContainer, + PollingAnswers, +}; From b7fac7bfc048d58be86dbbacbbf82b59de494e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Mon, 22 Jan 2024 16:59:09 -0300 Subject: [PATCH 0206/1039] Fix secret poll getting stuck --- .../ui/components/polling/polling-graphql/component.tsx | 1 + .../imports/ui/components/polling/polling-graphql/queries.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx index a26e9bc313..c0a0d421b3 100644 --- a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx @@ -350,6 +350,7 @@ const PollingGraphqlContainer: React.FC = () => { variables: { userId: currentUserData?.userId }, }, ); + console.log(hasPendingPollData, error); const [pollSubmitUserTypedVote] = useMutation(POLL_SUBMIT_TYPED_VOTE); const [pollSubmitUserVote] = useMutation(POLL_SUBMIT_VOTE); diff --git a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/queries.ts b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/queries.ts index 6dd88f4bbd..9ad89850bb 100644 --- a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/queries.ts +++ b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/queries.ts @@ -27,7 +27,8 @@ export const hasPendingPoll = gql` polls( where: { ended: { _eq: false } - users: { responded: { _eq: false }, userId: { _eq: $userId } } + users: { responded: { _eq: false }, userId: { _eq: $userId } }, + userCurrent: { responded: { _eq: false } } } ) { users { From 5c668aafa1bd0e153f9f2d61cde932c2ea9537bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Mon, 22 Jan 2024 17:15:37 -0300 Subject: [PATCH 0207/1039] Remove log --- .../imports/ui/components/polling/polling-graphql/component.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx index c0a0d421b3..a26e9bc313 100644 --- a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx @@ -350,7 +350,6 @@ const PollingGraphqlContainer: React.FC = () => { variables: { userId: currentUserData?.userId }, }, ); - console.log(hasPendingPollData, error); const [pollSubmitUserTypedVote] = useMutation(POLL_SUBMIT_TYPED_VOTE); const [pollSubmitUserVote] = useMutation(POLL_SUBMIT_VOTE); From 082996a15ece01d575e2598a36003fccfc8919be Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Mon, 22 Jan 2024 15:58:28 -0500 Subject: [PATCH 0208/1039] chore: Bump release to 3.0.0-alpha.2 --- bigbluebutton-config/bigbluebutton-release | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bigbluebutton-config/bigbluebutton-release b/bigbluebutton-config/bigbluebutton-release index a0b5197f82..89c096ec73 100644 --- a/bigbluebutton-config/bigbluebutton-release +++ b/bigbluebutton-config/bigbluebutton-release @@ -1 +1 @@ -BIGBLUEBUTTON_RELEASE=3.0.0-alpha.1 +BIGBLUEBUTTON_RELEASE=3.0.0-alpha.2 From 380287a4c862bd9a1c9e0bf3f3131d330f420dcd Mon Sep 17 00:00:00 2001 From: KDSBrowne Date: Wed, 8 Nov 2023 00:20:59 +0000 Subject: [PATCH 0209/1039] add transitions to cursors for smoothing --- .../ui/components/whiteboard/cursors/cursor/component.jsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/cursors/cursor/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/cursors/cursor/component.jsx index a4711d4d45..7db9283d3d 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/cursors/cursor/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/cursors/cursor/component.jsx @@ -25,19 +25,22 @@ const Cursor = (props) => { _y = (y + tldrawCamera?.point[1]) * tldrawCamera?.zoom; } + const transitionStyle = owner ? { transition: 'left 0.3s ease-out, top 0.3s ease-out' } : {}; + return ( <>
@@ -57,6 +60,7 @@ const Cursor = (props) => { color: '#FFF', backgroundColor: color, border: `1px solid ${color}`, + ...transitionStyle, }} data-test="whiteboardCursorIndicator" > From 957dd56ade22291cf968a4ddb3ee7eaced26651d Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Mon, 22 Jan 2024 16:33:58 -0500 Subject: [PATCH 0210/1039] feat: join param for default animations setting value --- .../api/users-settings/server/methods/addUserSettings.js | 1 + bigbluebutton-html5/imports/startup/client/base.jsx | 8 ++++++++ docs/docs/administration/customize.md | 1 + 3 files changed, 10 insertions(+) diff --git a/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js b/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js index 68e9682bb7..5d9bad360f 100644 --- a/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js +++ b/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js @@ -36,6 +36,7 @@ const currentParameters = [ 'bbb_skip_check_audio_on_first_join', 'bbb_fullaudio_bridge', 'bbb_transparent_listen_only', + 'bbb_show_animations_default', // BRANDING 'bbb_display_branding_area', // SHORTCUTS diff --git a/bigbluebutton-html5/imports/startup/client/base.jsx b/bigbluebutton-html5/imports/startup/client/base.jsx index 2ce86e3d57..bd52ad9d9b 100755 --- a/bigbluebutton-html5/imports/startup/client/base.jsx +++ b/bigbluebutton-html5/imports/startup/client/base.jsx @@ -177,6 +177,14 @@ class Base extends Component { if (Session.equals('layoutReady', true) && (sidebarContentPanel === PANELS.NONE || Session.equals('subscriptionsReady', true))) { if (!checkedUserSettings) { + const showAnimationsDefault = getFromUserSettings( + 'bbb_show_animations_default', + Meteor.settings.public.app.defaultSettings.application.animations + ); + + Settings.application.animations = showAnimationsDefault; + Settings.save(); + if (getFromUserSettings('bbb_show_participants_on_login', Meteor.settings.public.layout.showParticipantsOnLogin) && !deviceInfo.isPhone) { if (isChatEnabled() && getFromUserSettings('bbb_show_public_chat_on_login', !Meteor.settings.public.chat.startClosed)) { layoutContextDispatch({ diff --git a/docs/docs/administration/customize.md b/docs/docs/administration/customize.md index bf98a79667..0cd2fd98dd 100644 --- a/docs/docs/administration/customize.md +++ b/docs/docs/administration/customize.md @@ -1441,6 +1441,7 @@ Useful tools for development: | `userdata-bbb_skip_check_audio_on_first_join=` | (Introduced in BigBlueButton 2.3) If set to `true`, the user will not see the "echo test" when sharing audio for the first time in the session. If the user stops sharing, next time they try to share audio the echo test window will be displayed, allowing for configuration changes to be made prior to sharing audio again | `false` | | `userdata-bbb_override_default_locale=` | (Introduced in BigBlueButton 2.3) If set to `de`, the user's browser preference will be ignored - the client will be shown in 'de' (i.e. German) regardless of the otherwise preferred locale 'en' (or other) | `null` | | `userdata-bbb_hide_presentation_on_join` | (Introduced in BigBlueButton 2.6) If set to `true` it will make the user enter the meeting with presentation minimized, not permanent. | `false` | +| `userdata-bbb_show_animations_default` | (Introduced in BigBlueButton 2.7.4) If set to `false` the default value for the Animations toggle in Settings will be 'off' | `true` | #### Branding parameters From 02f86c8e0aebaf32ea76dac815c3ca1f481cc952 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Mon, 22 Jan 2024 22:43:32 -0300 Subject: [PATCH 0211/1039] Assure that the query will request the cursor field value in its list of fields --- .../internal/common/StreamCursorUtils.go | 43 +++++++++++++------ .../internal/common/types.go | 8 ++-- .../internal/hascli/conn/reader/reader.go | 2 +- .../internal/hascli/conn/writer/writer.go | 29 +++++++++---- .../hascli/retransmiter/retransmiter.go | 2 +- 5 files changed, 55 insertions(+), 29 deletions(-) diff --git a/bbb-graphql-middleware/internal/common/StreamCursorUtils.go b/bbb-graphql-middleware/internal/common/StreamCursorUtils.go index 76f9341503..872a13dc32 100644 --- a/bbb-graphql-middleware/internal/common/StreamCursorUtils.go +++ b/bbb-graphql-middleware/internal/common/StreamCursorUtils.go @@ -8,18 +8,18 @@ import ( ) func GetStreamCursorPropsFromQuery(payload map[string]interface{}, query string) (string, string, interface{}) { - streamCursorKey := "" - streamCursorVariable := "" + streamCursorField := "" + streamCursorVariableName := "" var streamCursorInitialValue interface{} cursorInitialValueRePattern := regexp.MustCompile(`cursor:\s*\{\s*initial_value\s*:\s*\{\s*([^:]+):\s*([^}]+)\s*}\s*}`) matches := cursorInitialValueRePattern.FindStringSubmatch(query) if matches != nil { - streamCursorKey = matches[1] + streamCursorField = matches[1] if strings.HasPrefix(matches[2], "$") { - streamCursorVariable, _ = strings.CutPrefix(matches[2], "$") + streamCursorVariableName, _ = strings.CutPrefix(matches[2], "$") if variables, okVariables := payload["variables"].(map[string]interface{}); okVariables { - if targetVariableValue, okTargetVariableValue := variables[streamCursorVariable]; okTargetVariableValue { + if targetVariableValue, okTargetVariableValue := variables[streamCursorVariableName]; okTargetVariableValue { streamCursorInitialValue = targetVariableValue } } @@ -28,10 +28,10 @@ func GetStreamCursorPropsFromQuery(payload map[string]interface{}, query string) } } - return streamCursorKey, streamCursorVariable, streamCursorInitialValue + return streamCursorField, streamCursorVariableName, streamCursorInitialValue } -func GetLastStreamCursorValueFromReceivedMessage(messageAsMap map[string]interface{}, streamCursorKey string) interface{} { +func GetLastStreamCursorValueFromReceivedMessage(messageAsMap map[string]interface{}, streamCursorField string) interface{} { var lastStreamCursorValue interface{} if payload, okPayload := messageAsMap["payload"].(map[string]interface{}); okPayload { @@ -43,7 +43,7 @@ func GetLastStreamCursorValueFromReceivedMessage(messageAsMap map[string]interfa // Get the last item directly (once it will contain the last cursor value) lastItemOfMessage := currentDataProp[len(currentDataProp)-1] if lastItemOfMessageAsMap, currDataOk := lastItemOfMessage.(map[string]interface{}); currDataOk { - if lastItemValue, okLastItemValue := lastItemOfMessageAsMap[streamCursorKey]; okLastItemValue { + if lastItemValue, okLastItemValue := lastItemOfMessageAsMap[streamCursorField]; okLastItemValue { lastStreamCursorValue = lastItemValue } } @@ -55,16 +55,31 @@ func GetLastStreamCursorValueFromReceivedMessage(messageAsMap map[string]interfa return lastStreamCursorValue } -func ReplaceMessageWithLastCursorValue(subscription GraphQlSubscription) interface{} { - var message = subscription.Message.(map[string]interface{}) +func PatchQueryIncludingCursorField(originalQuery string, cursorField string) string { + if cursorField == "" { + return originalQuery + } + + lastIndex := strings.LastIndex(originalQuery, "{") + if lastIndex == -1 { + return originalQuery + } + + // It will include the cursorField at the beginning of the list of fields + // It's not a problem if the field be duplicated in the list, Hasura just ignore the second occurrence + return originalQuery[:lastIndex+1] + "\n " + cursorField + originalQuery[lastIndex+1:] +} + +func PatchQuerySettingLastCursorValue(subscription GraphQlSubscription) interface{} { + message := subscription.Message payload, okPayload := message["payload"].(map[string]interface{}) if okPayload { - if subscription.StreamCursorVariable != "" { + if subscription.StreamCursorVariableName != "" { /**** This stream has its cursor value set through variables ****/ if variables, okVariables := payload["variables"].(map[string]interface{}); okVariables { - if variables[subscription.StreamCursorVariable] != subscription.StreamCursorCurrValue { - variables[subscription.StreamCursorVariable] = subscription.StreamCursorCurrValue + if variables[subscription.StreamCursorVariableName] != subscription.StreamCursorCurrValue { + variables[subscription.StreamCursorVariableName] = subscription.StreamCursorCurrValue payload["variables"] = variables message["payload"] = payload } @@ -100,7 +115,7 @@ func ReplaceMessageWithLastCursorValue(subscription GraphQlSubscription) interfa } if newValue != "" { - replacement := subscription.StreamCursorKey + ": " + newValue + replacement := subscription.StreamCursorField + ": " + newValue return fmt.Sprintf("cursor: {initial_value: {%s}}", replacement) } else { return match diff --git a/bbb-graphql-middleware/internal/common/types.go b/bbb-graphql-middleware/internal/common/types.go index 93ee15affe..15bc7e5db5 100644 --- a/bbb-graphql-middleware/internal/common/types.go +++ b/bbb-graphql-middleware/internal/common/types.go @@ -19,11 +19,11 @@ const ( type GraphQlSubscription struct { Id string - Message interface{} + Message map[string]interface{} Type QueryType OperationName string - StreamCursorKey string - StreamCursorVariable string + StreamCursorField string + StreamCursorVariableName string StreamCursorCurrValue interface{} JsonPatchSupported bool // indicate if client support Json Patch for this subscription LastSeenOnHasuraConnetion string // id of the hasura connection that this query was active @@ -35,7 +35,7 @@ type BrowserConnection struct { Context context.Context // browser connection context ActiveSubscriptions map[string]GraphQlSubscription // active subscriptions of this connection (start, but no stop) ActiveSubscriptionsMutex sync.RWMutex // mutex to control the map usage - ConnectionInitMessage interface{} // init message received in this connection (to be used on hasura reconnect) + ConnectionInitMessage map[string]interface{} // init message received in this connection (to be used on hasura reconnect) HasuraConnection *HasuraConnection // associated hasura connection Disconnected bool // indicate if the connection is gone } diff --git a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go index 0c4c213c83..c66a299c35 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go +++ b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go @@ -68,7 +68,7 @@ func HasuraConnectionReader(hc *common.HasuraConnection, fromHasuraToBrowserChan //Set last cursor value for stream if subscription.Type == common.Streaming { - lastCursor := common.GetLastStreamCursorValueFromReceivedMessage(messageAsMap, subscription.StreamCursorKey) + lastCursor := common.GetLastStreamCursorValueFromReceivedMessage(messageAsMap, subscription.StreamCursorField) if lastCursor != nil && subscription.StreamCursorCurrValue != lastCursor { subscription.StreamCursorCurrValue = lastCursor diff --git a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go index 3f10f51e60..334bc3c01a 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go +++ b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go @@ -41,8 +41,8 @@ RangeLoop: //Identify type based on query string messageType := common.Query - streamCursorKey := "" - streamCursorVariable := "" + streamCursorField := "" + streamCursorVariableName := "" var streamCursorInitialValue interface{} payload := fromBrowserMessageAsMap["payload"].(map[string]interface{}) @@ -53,7 +53,18 @@ RangeLoop: if strings.Contains(query, "_stream(") && strings.Contains(query, "cursor: {") { messageType = common.Streaming - streamCursorKey, streamCursorVariable, streamCursorInitialValue = common.GetStreamCursorPropsFromQuery(payload, query) + + browserConnection.ActiveSubscriptionsMutex.RLock() + _, queryIdExists := browserConnection.ActiveSubscriptions[queryId] + browserConnection.ActiveSubscriptionsMutex.RUnlock() + if !queryIdExists { + streamCursorField, streamCursorVariableName, streamCursorInitialValue = common.GetStreamCursorPropsFromQuery(payload, query) + + //It's necessary to assure the cursor field will return in the result of the query + //To be able to store the last received cursor value + payload["query"] = common.PatchQueryIncludingCursorField(query, streamCursorField) + fromBrowserMessageAsMap["payload"] = payload + } } if strings.Contains(query, "_aggregate") && strings.Contains(query, "aggregate {") { @@ -77,10 +88,10 @@ RangeLoop: browserConnection.ActiveSubscriptionsMutex.Lock() browserConnection.ActiveSubscriptions[queryId] = common.GraphQlSubscription{ Id: queryId, - Message: fromBrowserMessage, + Message: fromBrowserMessageAsMap, OperationName: operationName, - StreamCursorKey: streamCursorKey, - StreamCursorVariable: streamCursorVariable, + StreamCursorField: streamCursorField, + StreamCursorVariableName: streamCursorVariableName, StreamCursorCurrValue: streamCursorInitialValue, LastSeenOnHasuraConnetion: hc.Id, JsonPatchSupported: jsonPatchSupported, @@ -105,11 +116,11 @@ RangeLoop: } if fromBrowserMessageAsMap["type"] == "connection_init" { - browserConnection.ConnectionInitMessage = fromBrowserMessage + browserConnection.ConnectionInitMessage = fromBrowserMessageAsMap } - log.Tracef("sending to hasura: %v", fromBrowserMessage) - err := wsjson.Write(hc.Context, hc.Websocket, fromBrowserMessage) + log.Tracef("sending to hasura: %v", fromBrowserMessageAsMap) + err := wsjson.Write(hc.Context, hc.Websocket, fromBrowserMessageAsMap) if err != nil { log.Errorf("error on write (we're disconnected from hasura): %v", err) return diff --git a/bbb-graphql-middleware/internal/hascli/retransmiter/retransmiter.go b/bbb-graphql-middleware/internal/hascli/retransmiter/retransmiter.go index 30afeb2c77..530d167e42 100644 --- a/bbb-graphql-middleware/internal/hascli/retransmiter/retransmiter.go +++ b/bbb-graphql-middleware/internal/hascli/retransmiter/retransmiter.go @@ -15,7 +15,7 @@ func RetransmitSubscriptionStartMessages(hc *common.HasuraConnection, fromBrowse log.Tracef("retransmiting subscription start: %v", subscription.Message) if subscription.Type == common.Streaming && subscription.StreamCursorCurrValue != nil { - fromBrowserToHasuraChannel.Send(common.ReplaceMessageWithLastCursorValue(subscription)) + fromBrowserToHasuraChannel.Send(common.PatchQuerySettingLastCursorValue(subscription)) } else { fromBrowserToHasuraChannel.Send(subscription.Message) } From 9083d1a2918cd18fa944b07d5192f674567f3951 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Tue, 23 Jan 2024 09:12:04 -0300 Subject: [PATCH 0212/1039] Allow to select voice props along with userCamera in Graphql --- .../BigBlueButton/tables/public_v_user_camera.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_camera.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_camera.yaml index 66086faada..732a58f20c 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_camera.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_camera.yaml @@ -16,6 +16,15 @@ object_relationships: remote_table: name: v_user_ref schema: public + - name: voice + using: + manual_configuration: + column_mapping: + userId: userId + insertion_order: null + remote_table: + name: v_user_voice + schema: public select_permissions: - role: bbb_client permission: From ddfce55213eaad2fd14dca4144f2f80543561e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Tue, 23 Jan 2024 09:33:59 -0300 Subject: [PATCH 0213/1039] remove clearWhiteboard action --- .../imports/api/annotations/server/methods.js | 2 -- .../server/methods/clearWhiteboard.js | 27 ------------------- 2 files changed, 29 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/annotations/server/methods/clearWhiteboard.js diff --git a/bigbluebutton-html5/imports/api/annotations/server/methods.js b/bigbluebutton-html5/imports/api/annotations/server/methods.js index 6a42fbf0bb..cafab18911 100644 --- a/bigbluebutton-html5/imports/api/annotations/server/methods.js +++ b/bigbluebutton-html5/imports/api/annotations/server/methods.js @@ -1,11 +1,9 @@ import { Meteor } from 'meteor/meteor'; -import clearWhiteboard from './methods/clearWhiteboard'; import sendAnnotations from './methods/sendAnnotations'; import sendBulkAnnotations from './methods/sendBulkAnnotations'; import deleteAnnotations from './methods/deleteAnnotations'; Meteor.methods({ - clearWhiteboard, sendAnnotations, sendBulkAnnotations, deleteAnnotations, diff --git a/bigbluebutton-html5/imports/api/annotations/server/methods/clearWhiteboard.js b/bigbluebutton-html5/imports/api/annotations/server/methods/clearWhiteboard.js deleted file mode 100644 index a95d775dba..0000000000 --- a/bigbluebutton-html5/imports/api/annotations/server/methods/clearWhiteboard.js +++ /dev/null @@ -1,27 +0,0 @@ -import RedisPubSub from '/imports/startup/server/redis'; -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import Logger from '/imports/startup/server/logger'; - -export default function clearWhiteboard(whiteboardId) { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'ClearWhiteboardPubMsg'; - - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(whiteboardId, String); - - const payload = { - whiteboardId, - }; - - return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method clearWhiteboard ${err.stack}`); - } -} From b674d8a9121ef63d7dae1ace5a695d4dd9b1d6ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Tue, 23 Jan 2024 09:51:14 -0300 Subject: [PATCH 0214/1039] migrate setPresentation action --- .../api/presentations/server/methods.js | 2 -- .../server/methods/setPresentation.js | 28 ------------------- .../actions-dropdown/container.jsx | 9 ++++-- .../notes/notes-dropdown/component.jsx | 3 +- .../notes/notes-dropdown/container.jsx | 11 ++++++-- .../notes/notes-dropdown/service.js | 5 ++-- .../ui/components/presentation/mutations.jsx | 9 ++++++ .../presentation-uploader/component.jsx | 3 +- .../presentation-uploader/container.jsx | 8 +++++- .../presentation-uploader/service.js | 15 +++++----- 10 files changed, 45 insertions(+), 48 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/presentations/server/methods/setPresentation.js diff --git a/bigbluebutton-html5/imports/api/presentations/server/methods.js b/bigbluebutton-html5/imports/api/presentations/server/methods.js index f772ab0b5b..1134b35779 100644 --- a/bigbluebutton-html5/imports/api/presentations/server/methods.js +++ b/bigbluebutton-html5/imports/api/presentations/server/methods.js @@ -1,8 +1,6 @@ import { Meteor } from 'meteor/meteor'; import removePresentation from './methods/removePresentation'; -import setPresentation from './methods/setPresentation'; Meteor.methods({ removePresentation, - setPresentation, }); diff --git a/bigbluebutton-html5/imports/api/presentations/server/methods/setPresentation.js b/bigbluebutton-html5/imports/api/presentations/server/methods/setPresentation.js deleted file mode 100644 index 3ff16aee82..0000000000 --- a/bigbluebutton-html5/imports/api/presentations/server/methods/setPresentation.js +++ /dev/null @@ -1,28 +0,0 @@ -import RedisPubSub from '/imports/startup/server/redis'; -import { check } from 'meteor/check'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import Logger from '/imports/startup/server/logger'; - -export default function setPresentation(presentationId, podId) { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'SetCurrentPresentationPubMsg'; - - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(presentationId, String); - check(podId, String); - - const payload = { - presentationId, - podId, - }; - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method setPresentation ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/actions-dropdown/container.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/actions-dropdown/container.jsx index 3c39d2b0d0..c7b4c8a25d 100644 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/actions-dropdown/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/actions-dropdown/container.jsx @@ -1,5 +1,4 @@ import React, { useContext } from 'react'; -import PresentationUploaderService from '/imports/ui/components/presentation/presentation-uploader/service'; import ActionsDropdown from './component'; import { layoutSelectInput, layoutDispatch, layoutSelect } from '../../layout/context'; import { SMALL_VIEWPORT_BREAKPOINT, ACTIONS, PANELS } from '../../layout/enums'; @@ -12,6 +11,7 @@ import { import { SET_PRESENTER } from '/imports/ui/core/graphql/mutations/userMutations'; import { TIMER_ACTIVATE, TIMER_DEACTIVATE } from '../../timer/mutations'; import Auth from '/imports/ui/services/auth'; +import { PRESENTATION_SET_CURRENT } from '../../presentation/mutations'; const TIMER_CONFIG = Meteor.settings.public.timer; const MILLI_IN_MINUTE = 60000; @@ -35,11 +35,16 @@ const ActionsDropdownContainer = (props) => { const [setPresenter] = useMutation(SET_PRESENTER); const [timerActivate] = useMutation(TIMER_ACTIVATE); const [timerDeactivate] = useMutation(TIMER_DEACTIVATE); + const [presentationSetCurrent] = useMutation(PRESENTATION_SET_CURRENT); const handleTakePresenter = () => { setPresenter({ variables: { userId: Auth.userID } }); }; + const setPresentation = (presentationId) => { + presentationSetCurrent({ variables: { presentationId } }); + }; + const activateTimer = () => { const stopwatch = true; const running = false; @@ -71,7 +76,7 @@ const ActionsDropdownContainer = (props) => { presentations, isTimerFeatureEnabled: isTimerFeatureEnabled(), isDropdownOpen: Session.get('dropdownOpen'), - setPresentation: PresentationUploaderService.setPresentation, + setPresentation, isCameraAsContentEnabled: isCameraAsContentEnabled(), handleTakePresenter, activateTimer, diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.jsx index 263744f597..a836418d60 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.jsx @@ -51,6 +51,7 @@ class NotesDropdown extends PureComponent { intl, amIPresenter, presentations, + setPresentation, } = this.props; const { converterButtonDisabled } = this.state; @@ -71,7 +72,7 @@ class NotesDropdown extends PureComponent { onClick: () => { this.setConverterButtonDisabled(true); setTimeout(() => this.setConverterButtonDisabled(false), DEBOUNCE_TIMEOUT); - return Service.convertAndUpload(presentations); + return Service.convertAndUpload(presentations, setPresentation); }, }, ); diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/container.jsx b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/container.jsx index e736b07c63..9e74d8c0dc 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/container.jsx @@ -1,11 +1,12 @@ import React from 'react'; import NotesDropdown from './component'; import { layoutSelect } from '/imports/ui/components/layout/context'; -import { useSubscription } from '@apollo/client'; +import { useSubscription, useMutation } from '@apollo/client'; import { PROCESSED_PRESENTATIONS_SUBSCRIPTION, } from '/imports/ui/components/whiteboard/queries'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import { PRESENTATION_SET_CURRENT } from '../../presentation/mutations'; const NotesDropdownContainer = ({ ...props }) => { const { data: currentUserData } = useCurrentUser((user) => ({ @@ -17,7 +18,13 @@ const NotesDropdownContainer = ({ ...props }) => { const { data: presentationData } = useSubscription(PROCESSED_PRESENTATIONS_SUBSCRIPTION); const presentations = presentationData?.pres_presentation || []; - return ; + const [presentationSetCurrent] = useMutation(PRESENTATION_SET_CURRENT); + + const setPresentation = (presentationId) => { + presentationSetCurrent({ variables: { presentationId } }); + }; + + return ; }; export default NotesDropdownContainer; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/service.js b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/service.js index afc7d7b1bb..a30b9ff057 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/service.js +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/service.js @@ -7,8 +7,7 @@ import { uniqueId } from '/imports/utils/string-utils'; const PADS_CONFIG = Meteor.settings.public.pads; -async function convertAndUpload(presentations) { - +async function convertAndUpload(presentations, setPresentation) { let filename = 'Shared_Notes'; const duplicates = presentations.filter((pres) => pres.filename?.startsWith(filename) || pres.name?.startsWith(filename)).length; @@ -53,7 +52,7 @@ async function convertAndUpload(presentations) { onUpload: () => { }, onProgress: () => { }, onDone: () => { }, - }); + }, setPresentation); } export default { diff --git a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx index 78a5a95549..3442b5ac8d 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx @@ -45,9 +45,18 @@ export const PRESENTATION_SET_DOWNLOADABLE = gql` } `; +export const PRESENTATION_SET_CURRENT = gql` + mutation PresentationSetCurrent($presentationId: String!) { + presentationSetCurrent( + presentationId: $presentationId, + ) + } +`; + export default { PRESENTATION_SET_ZOOM, PRESENTATION_SET_WRITERS, PRESENTATION_SET_PAGE, PRESENTATION_SET_DOWNLOADABLE, + PRESENTATION_SET_CURRENT, }; diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx index 0c09d9d775..26d5859083 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx @@ -583,6 +583,7 @@ class PresentationUploader extends Component { selectedToBeNextCurrent, presentations: propPresentations, dispatchChangePresentationDownloadable, + setPresentation, } = this.props; const { disableActions, presentations } = this.state; const presentationsToSave = presentations; @@ -610,7 +611,7 @@ class PresentationUploader extends Component { if (!disableActions) { Session.set('showUploadPresentationView', false); - return handleSave(presentationsToSave, true, {}, propPresentations) + return handleSave(presentationsToSave, true, {}, propPresentations, setPresentation) .then(() => { const hasError = presentations.some((p) => !!p.uploadErrorMsgKey); if (!hasError) { diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx index 1364e528aa..20867af21d 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx @@ -17,7 +17,7 @@ import { PRESENTATIONS_SUBSCRIPTION, } from '/imports/ui/components/whiteboard/queries'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; -import { PRESENTATION_SET_DOWNLOADABLE } from '../mutations'; +import { PRESENTATION_SET_DOWNLOADABLE, PRESENTATION_SET_CURRENT } from '../mutations'; const PRESENTATION_CONFIG = Meteor.settings.public.presentation; @@ -32,6 +32,7 @@ const PresentationUploaderContainer = (props) => { const currentPresentation = presentations.find((p) => p.current)?.presentationId || ''; const [presentationSetDownloadable] = useMutation(PRESENTATION_SET_DOWNLOADABLE); + const [presentationSetCurrent] = useMutation(PRESENTATION_SET_CURRENT); const exportPresentation = (presentationId, fileStateType) => { presentationSetDownloadable({ @@ -53,6 +54,10 @@ const PresentationUploaderContainer = (props) => { }); }; + const setPresentation = (presentationId) => { + presentationSetCurrent({ variables: { presentationId } }); + }; + return userIsPresenter && ( { currentPresentation={currentPresentation} exportPresentation={exportPresentation} dispatchChangePresentationDownloadable={dispatchChangePresentationDownloadable} + setPresentation={setPresentation} {...props} /> diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js index 2719e2c52c..944fbdb661 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js @@ -179,10 +179,6 @@ const uploadAndConvertPresentations = ( p.onUpload, p.onProgress, p.onConversion, p.current, ))); -const setPresentation = (presentationId) => { - makeCall('setPresentation', presentationId, POD_ID); -}; - const removePresentation = (presentationId) => { makeCall('removePresentation', presentationId, POD_ID); }; @@ -191,7 +187,7 @@ const removePresentations = ( presentationsToRemove, ) => Promise.all(presentationsToRemove.map((p) => removePresentation(p.presentationId, POD_ID))); -const persistPresentationChanges = (oldState, newState, uploadEndpoint) => { +const persistPresentationChanges = (oldState, newState, uploadEndpoint, setPresentation) => { const presentationsToUpload = newState.filter((p) => !p.uploadCompleted); const presentationsToRemove = oldState.filter((p) => !newState.find((u) => { return u.presentationId === p.presentationId })); @@ -231,7 +227,11 @@ const persistPresentationChanges = (oldState, newState, uploadEndpoint) => { }; const handleSavePresentation = ( - presentations = [], isFromPresentationUploaderInterface = true, newPres = {}, currentPresentations = [], + presentations = [], + isFromPresentationUploaderInterface = true, + newPres = {}, + currentPresentations = [], + setPresentation, ) => { if (!isPresentationEnabled()) { return null; @@ -253,7 +253,7 @@ const handleSavePresentation = ( currentPresentations, presentations, PRESENTATION_CONFIG.uploadEndpoint, - 'DEFAULT_PRESENTATION_POD', + setPresentation, ); }; @@ -348,7 +348,6 @@ function handleFiledrop(files, files2, that, intl, intlMessages) { export default { handleSavePresentation, persistPresentationChanges, - setPresentation, requestPresentationUploadToken, getExternalUploadData, uploadAndConvertPresentation, From 1388c74c2e5c50be655ff4a59299eb8d8012a686 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Tue, 23 Jan 2024 09:51:19 -0300 Subject: [PATCH 0215/1039] Fix duplicating log message --- .../src/main/scala/org/bigbluebutton/ClientSettings.scala | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/ClientSettings.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/ClientSettings.scala index 53000a6507..7254224129 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/ClientSettings.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/ClientSettings.scala @@ -10,6 +10,7 @@ import scala.util.{ Failure, Success, Try } object ClientSettings extends SystemConfiguration { var clientSettingsFromFile: Map[String, Object] = Map("" -> "") val logger = LoggerFactory.getLogger(this.getClass) + val def loadClientSettingsFromFile() = { val clientSettingsFile = scala.io.Source.fromFile(clientSettingsPath, "UTF-8") @@ -56,7 +57,7 @@ object ClientSettings extends SystemConfiguration { getConfigPropertyValueByPath(map, path) match { case Some(configValue: Int) => configValue case _ => - logger.debug("Config `{}` not found.", path) + logger.debug(s"Config `$path` with type Integer not found in clientSettings.") alternativeValue } } @@ -65,7 +66,7 @@ object ClientSettings extends SystemConfiguration { getConfigPropertyValueByPath(map, path) match { case Some(configValue: String) => configValue case _ => - logger.debug("Config `{}` not found.", path) + logger.debug(s"Config `$path` with type String not found in clientSettings.") alternativeValue } } @@ -74,7 +75,7 @@ object ClientSettings extends SystemConfiguration { getConfigPropertyValueByPath(map, path) match { case Some(configValue: Boolean) => configValue case _ => - logger.debug("Config `{}` not found.", path) + logger.debug(s"Config `$path` with type Boolean found in clientSettings.") alternativeValue } } From 23389010fcad63376ba93cb3de6b0d1d71d375b8 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Tue, 23 Jan 2024 09:52:09 -0300 Subject: [PATCH 0216/1039] Remove wrong line --- .../src/main/scala/org/bigbluebutton/ClientSettings.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/ClientSettings.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/ClientSettings.scala index 7254224129..1b84d040fd 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/ClientSettings.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/ClientSettings.scala @@ -10,7 +10,6 @@ import scala.util.{ Failure, Success, Try } object ClientSettings extends SystemConfiguration { var clientSettingsFromFile: Map[String, Object] = Map("" -> "") val logger = LoggerFactory.getLogger(this.getClass) - val def loadClientSettingsFromFile() = { val clientSettingsFile = scala.io.Source.fromFile(clientSettingsPath, "UTF-8") From 0dcb2bc7a2d0fdf6ce2778794483d991cb096e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Tue, 23 Jan 2024 10:55:40 -0300 Subject: [PATCH 0217/1039] migrate removePresentation action --- .../imports/api/presentations/server/index.js | 1 - .../api/presentations/server/methods.js | 6 ---- .../server/methods/removePresentation.js | 28 ------------------- .../notes/notes-dropdown/component.jsx | 3 +- .../notes/notes-dropdown/container.jsx | 21 ++++++++++++-- .../notes/notes-dropdown/service.js | 6 ++-- .../ui/components/presentation/mutations.jsx | 9 ++++++ .../presentation-uploader/component.jsx | 10 ++++++- .../presentation-uploader/container.jsx | 8 +++++- .../presentation-uploader/service.js | 23 +++++++++------ bigbluebutton-html5/server/main.js | 1 - 11 files changed, 64 insertions(+), 52 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/presentations/server/index.js delete mode 100644 bigbluebutton-html5/imports/api/presentations/server/methods.js delete mode 100644 bigbluebutton-html5/imports/api/presentations/server/methods/removePresentation.js diff --git a/bigbluebutton-html5/imports/api/presentations/server/index.js b/bigbluebutton-html5/imports/api/presentations/server/index.js deleted file mode 100644 index ba55c4a16e..0000000000 --- a/bigbluebutton-html5/imports/api/presentations/server/index.js +++ /dev/null @@ -1 +0,0 @@ -import './methods'; diff --git a/bigbluebutton-html5/imports/api/presentations/server/methods.js b/bigbluebutton-html5/imports/api/presentations/server/methods.js deleted file mode 100644 index 1134b35779..0000000000 --- a/bigbluebutton-html5/imports/api/presentations/server/methods.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import removePresentation from './methods/removePresentation'; - -Meteor.methods({ - removePresentation, -}); diff --git a/bigbluebutton-html5/imports/api/presentations/server/methods/removePresentation.js b/bigbluebutton-html5/imports/api/presentations/server/methods/removePresentation.js deleted file mode 100644 index 8a2ef1e898..0000000000 --- a/bigbluebutton-html5/imports/api/presentations/server/methods/removePresentation.js +++ /dev/null @@ -1,28 +0,0 @@ -import RedisPubSub from '/imports/startup/server/redis'; -import { check } from 'meteor/check'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import Logger from '/imports/startup/server/logger'; - -export default function removePresentation(presentationId, podId) { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'RemovePresentationPubMsg'; - - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(presentationId, String); - check(podId, String); - - const payload = { - presentationId, - podId, - }; - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method removePresentation ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.jsx index a836418d60..9e59a9987d 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.jsx @@ -52,6 +52,7 @@ class NotesDropdown extends PureComponent { amIPresenter, presentations, setPresentation, + removePresentation, } = this.props; const { converterButtonDisabled } = this.state; @@ -72,7 +73,7 @@ class NotesDropdown extends PureComponent { onClick: () => { this.setConverterButtonDisabled(true); setTimeout(() => this.setConverterButtonDisabled(false), DEBOUNCE_TIMEOUT); - return Service.convertAndUpload(presentations, setPresentation); + return Service.convertAndUpload(presentations, setPresentation, removePresentation); }, }, ); diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/container.jsx b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/container.jsx index 9e74d8c0dc..122d11e1cd 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/container.jsx @@ -6,7 +6,7 @@ import { PROCESSED_PRESENTATIONS_SUBSCRIPTION, } from '/imports/ui/components/whiteboard/queries'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; -import { PRESENTATION_SET_CURRENT } from '../../presentation/mutations'; +import { PRESENTATION_SET_CURRENT, PRESENTATION_REMOVE } from '../../presentation/mutations'; const NotesDropdownContainer = ({ ...props }) => { const { data: currentUserData } = useCurrentUser((user) => ({ @@ -19,12 +19,29 @@ const NotesDropdownContainer = ({ ...props }) => { const presentations = presentationData?.pres_presentation || []; const [presentationSetCurrent] = useMutation(PRESENTATION_SET_CURRENT); + const [presentationRemove] = useMutation(PRESENTATION_REMOVE); const setPresentation = (presentationId) => { presentationSetCurrent({ variables: { presentationId } }); }; - return ; + const removePresentation = (presentationId) => { + presentationRemove({ variables: { presentationId } }); + }; + + return ( + + ); }; export default NotesDropdownContainer; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/service.js b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/service.js index a30b9ff057..c28f3089c4 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/service.js +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/service.js @@ -7,7 +7,7 @@ import { uniqueId } from '/imports/utils/string-utils'; const PADS_CONFIG = Meteor.settings.public.pads; -async function convertAndUpload(presentations, setPresentation) { +async function convertAndUpload(presentations, setPresentation, removePresentation) { let filename = 'Shared_Notes'; const duplicates = presentations.filter((pres) => pres.filename?.startsWith(filename) || pres.name?.startsWith(filename)).length; @@ -52,7 +52,9 @@ async function convertAndUpload(presentations, setPresentation) { onUpload: () => { }, onProgress: () => { }, onDone: () => { }, - }, setPresentation); + }, + setPresentation, + removePresentation); } export default { diff --git a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx index 3442b5ac8d..ee698aae4b 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx @@ -53,10 +53,19 @@ export const PRESENTATION_SET_CURRENT = gql` } `; +export const PRESENTATION_REMOVE = gql` + mutation PresentationRemove($presentationId: String!) { + presentationRemove( + presentationId: $presentationId, + ) + } +`; + export default { PRESENTATION_SET_ZOOM, PRESENTATION_SET_WRITERS, PRESENTATION_SET_PAGE, PRESENTATION_SET_DOWNLOADABLE, PRESENTATION_SET_CURRENT, + PRESENTATION_REMOVE, }; diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx index 26d5859083..e09a061567 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx @@ -584,6 +584,7 @@ class PresentationUploader extends Component { presentations: propPresentations, dispatchChangePresentationDownloadable, setPresentation, + removePresentation, } = this.props; const { disableActions, presentations } = this.state; const presentationsToSave = presentations; @@ -611,7 +612,14 @@ class PresentationUploader extends Component { if (!disableActions) { Session.set('showUploadPresentationView', false); - return handleSave(presentationsToSave, true, {}, propPresentations, setPresentation) + return handleSave( + presentationsToSave, + true, + {}, + propPresentations, + setPresentation, + removePresentation, + ) .then(() => { const hasError = presentations.some((p) => !!p.uploadErrorMsgKey); if (!hasError) { diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx index 20867af21d..181f09cbf9 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx @@ -17,7 +17,7 @@ import { PRESENTATIONS_SUBSCRIPTION, } from '/imports/ui/components/whiteboard/queries'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; -import { PRESENTATION_SET_DOWNLOADABLE, PRESENTATION_SET_CURRENT } from '../mutations'; +import { PRESENTATION_SET_DOWNLOADABLE, PRESENTATION_SET_CURRENT, PRESENTATION_REMOVE } from '../mutations'; const PRESENTATION_CONFIG = Meteor.settings.public.presentation; @@ -33,6 +33,7 @@ const PresentationUploaderContainer = (props) => { const [presentationSetDownloadable] = useMutation(PRESENTATION_SET_DOWNLOADABLE); const [presentationSetCurrent] = useMutation(PRESENTATION_SET_CURRENT); + const [presentationRemove] = useMutation(PRESENTATION_REMOVE); const exportPresentation = (presentationId, fileStateType) => { presentationSetDownloadable({ @@ -58,6 +59,10 @@ const PresentationUploaderContainer = (props) => { presentationSetCurrent({ variables: { presentationId } }); }; + const removePresentation = (presentationId) => { + presentationRemove({ variables: { presentationId } }); + }; + return userIsPresenter && ( { exportPresentation={exportPresentation} dispatchChangePresentationDownloadable={dispatchChangePresentationDownloadable} setPresentation={setPresentation} + removePresentation={removePresentation} {...props} /> diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js index 944fbdb661..459740f021 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js @@ -179,15 +179,18 @@ const uploadAndConvertPresentations = ( p.onUpload, p.onProgress, p.onConversion, p.current, ))); -const removePresentation = (presentationId) => { - makeCall('removePresentation', presentationId, POD_ID); -}; - const removePresentations = ( presentationsToRemove, -) => Promise.all(presentationsToRemove.map((p) => removePresentation(p.presentationId, POD_ID))); + removePresentation, +) => Promise.all(presentationsToRemove.map((p) => removePresentation(p.presentationId))); -const persistPresentationChanges = (oldState, newState, uploadEndpoint, setPresentation) => { +const persistPresentationChanges = ( + oldState, + newState, + uploadEndpoint, + setPresentation, + removePresentation, +) => { const presentationsToUpload = newState.filter((p) => !p.uploadCompleted); const presentationsToRemove = oldState.filter((p) => !newState.find((u) => { return u.presentationId === p.presentationId })); @@ -206,7 +209,7 @@ const persistPresentationChanges = (oldState, newState, uploadEndpoint, setPrese }) .then((presentations) => { if (currentPresentation === undefined) { - setPresentation('', POD_ID); + setPresentation(''); return Promise.resolve(); } @@ -221,9 +224,9 @@ const persistPresentationChanges = (oldState, newState, uploadEndpoint, setPrese return Promise.resolve(); } - return setPresentation(currentPresentation?.presentationId, POD_ID); + return setPresentation(currentPresentation?.presentationId); }) - .then(removePresentations.bind(null, presentationsToRemove, POD_ID)); + .then(removePresentations.bind(null, presentationsToRemove, removePresentation)); }; const handleSavePresentation = ( @@ -232,6 +235,7 @@ const handleSavePresentation = ( newPres = {}, currentPresentations = [], setPresentation, + removePresentation, ) => { if (!isPresentationEnabled()) { return null; @@ -254,6 +258,7 @@ const handleSavePresentation = ( presentations, PRESENTATION_CONFIG.uploadEndpoint, setPresentation, + removePresentation, ); }; diff --git a/bigbluebutton-html5/server/main.js b/bigbluebutton-html5/server/main.js index 739d7f3c09..1a33749f2f 100755 --- a/bigbluebutton-html5/server/main.js +++ b/bigbluebutton-html5/server/main.js @@ -7,7 +7,6 @@ import '/imports/api/annotations/server'; import '/imports/api/cursor/server'; import '/imports/api/polls/server'; import '/imports/api/captions/server'; -import '/imports/api/presentations/server'; import '/imports/api/presentation-upload-token/server'; import '/imports/api/breakouts/server'; import '/imports/api/breakouts-history/server'; From 00e881dfd662c97eb7878ea904e5d4b794cb6b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Tue, 23 Jan 2024 11:31:39 -0300 Subject: [PATCH 0218/1039] migrate deleteAnnotations action --- .../imports/api/annotations/server/methods.js | 2 - .../server/methods/deleteAnnotations.js | 29 ----- .../ui/components/presentation/mutations.jsx | 10 ++ .../ui/components/whiteboard/component.jsx | 3 +- .../ui/components/whiteboard/container.jsx | 13 +- .../ui/components/whiteboard/service.js | 3 - .../imports/ui/components/whiteboard/utils.js | 117 +----------------- 7 files changed, 24 insertions(+), 153 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/annotations/server/methods/deleteAnnotations.js diff --git a/bigbluebutton-html5/imports/api/annotations/server/methods.js b/bigbluebutton-html5/imports/api/annotations/server/methods.js index cafab18911..a2721ec9e9 100644 --- a/bigbluebutton-html5/imports/api/annotations/server/methods.js +++ b/bigbluebutton-html5/imports/api/annotations/server/methods.js @@ -1,10 +1,8 @@ import { Meteor } from 'meteor/meteor'; import sendAnnotations from './methods/sendAnnotations'; import sendBulkAnnotations from './methods/sendBulkAnnotations'; -import deleteAnnotations from './methods/deleteAnnotations'; Meteor.methods({ sendAnnotations, sendBulkAnnotations, - deleteAnnotations, }); diff --git a/bigbluebutton-html5/imports/api/annotations/server/methods/deleteAnnotations.js b/bigbluebutton-html5/imports/api/annotations/server/methods/deleteAnnotations.js deleted file mode 100644 index a255ab9364..0000000000 --- a/bigbluebutton-html5/imports/api/annotations/server/methods/deleteAnnotations.js +++ /dev/null @@ -1,29 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import RedisPubSub from '/imports/startup/server/redis'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import { check } from 'meteor/check'; -import Logger from '/imports/startup/server/logger'; - -export default function deleteAnnotations(annotations, whiteboardId) { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'DeleteWhiteboardAnnotationsPubMsg'; - - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(whiteboardId, String); - check(annotations, Array); - - const payload = { - whiteboardId, - annotationsIds: annotations, - }; - - return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method deleteAnnotation ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx index ee698aae4b..4a6d9aecfb 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx @@ -61,6 +61,15 @@ export const PRESENTATION_REMOVE = gql` } `; +export const PRES_ANNOTATION_DELETE = gql` + mutation PresAnnotationDelete($pageId: String!, $annotationsIds: [String]!) { + presAnnotationDelete( + pageId: $pageId, + annotationsIds: $annotationsIds, + ) + } +`; + export default { PRESENTATION_SET_ZOOM, PRESENTATION_SET_WRITERS, @@ -68,4 +77,5 @@ export default { PRESENTATION_SET_DOWNLOADABLE, PRESENTATION_SET_CURRENT, PRESENTATION_REMOVE, + PRES_ANNOTATION_DELETE, }; diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx index 9cddcda5c7..a06223b32f 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx @@ -20,7 +20,6 @@ import { findRemoved, filterInvalidShapes, mapLanguage, - sendShapeChanges, usePrevious, } from "./utils"; // import { throttle } from "/imports/utils/throttle"; @@ -720,7 +719,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) { }); Object.values(removed).forEach((record) => { - removeShapes([record.id], whiteboardId); + removeShapes([record.id]); }); }, { source: "user", scope: "document" } diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx index 2266f8507b..cf3b3fa3f9 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx @@ -10,7 +10,6 @@ import { CURSOR_SUBSCRIPTION } from './cursors/queries'; import { initDefaultPages, persistShape, - removeShapes, notifyNotAllowedChange, notifyShapeNumberExceeded, toggleToolsAnimations, @@ -33,7 +32,7 @@ import useMeeting from '/imports/ui/core/hooks/useMeeting'; import { AssetRecordType, } from "@tldraw/tldraw"; -import { PRESENTATION_SET_ZOOM } from '../presentation/mutations'; +import { PRESENTATION_SET_ZOOM, PRES_ANNOTATION_DELETE } from '../presentation/mutations'; const WHITEBOARD_CONFIG = Meteor.settings.public.whiteboard; @@ -62,6 +61,16 @@ const WhiteboardContainer = (props) => { const hasWBAccess = whiteboardWriters?.some((writer) => writer.userId === Auth.userID); const [presentationSetZoom] = useMutation(PRESENTATION_SET_ZOOM); + const [presentationDeleteAnnotations] = useMutation(PRES_ANNOTATION_DELETE); + + const removeShapes = (shapeIds) => { + presentationDeleteAnnotations({ + variables: { + pageId: currentPresentationPage?.pageId, + annotationsIds: shapeIds, + }, + }); + }; const zoomSlide = (widthRatio, heightRatio, xOffset, yOffset) => { const { pageId, num } = currentPresentationPage; diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/service.js b/bigbluebutton-html5/imports/ui/components/whiteboard/service.js index 6043a7f426..1be7fb562f 100755 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/service.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/service.js @@ -98,8 +98,6 @@ const persistShape = (shape, whiteboardId, isModerator) => { sendAnnotation(annotation); }; -const removeShapes = (shapes, whiteboardId) => makeCall('deleteAnnotations', shapes, whiteboardId); - const initDefaultPages = (count = 1) => { const pages = {}; const pageStates = {}; @@ -279,7 +277,6 @@ export { sendAnnotation, getMultiUser, persistShape, - removeShapes, notifyNotAllowedChange, notifyShapeNumberExceeded, toggleToolsAnimations, diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/utils.js b/bigbluebutton-html5/imports/ui/components/whiteboard/utils.js index 476af5e148..f2fdc45d5c 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/utils.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/utils.js @@ -2,7 +2,6 @@ import React from 'react'; import { isEqual } from 'radash'; import { persistShape, - removeShapes, notifyNotAllowedChange, notifyShapeNumberExceeded, } from './service'; @@ -90,118 +89,6 @@ const isValidShapeType = (shape) => { return !invalidTypes.includes(shape?.type); }; -const sendShapeChanges = ( - app, - changedShapes, - shapes, - prevShapes, - hasShapeAccess, - whiteboardId, - currentUser, - intl, - redo = false, -) => { - let isModerator = currentUser?.role === ROLE_MODERATOR; - - const invalidChange = Object.keys(changedShapes) - .find((id) => !hasShapeAccess(id)); - - const invalidShapeType = Object.keys(changedShapes) - .find((id) => !isValidShapeType(changedShapes[id])); - - const currentShapes = app?.document?.pages[app?.currentPageId]?.shapes; - const { maxNumberOfAnnotations } = WHITEBOARD_CONFIG; - // -1 for background shape - const shapeNumberExceeded = Object.keys(currentShapes).length - 1 > maxNumberOfAnnotations; - - const isInserting = Object.keys(changedShapes) - .filter( - (shape) => typeof changedShapes[shape] === 'object' - && changedShapes[shape].type - && !prevShapes[shape], - ).length !== 0; - - if (invalidChange || invalidShapeType || (shapeNumberExceeded && isInserting)) { - if (shapeNumberExceeded) { - notifyShapeNumberExceeded(intl, maxNumberOfAnnotations); - } else { - notifyNotAllowedChange(intl); - } - const modApp = app; - // undo last command without persisting to not generate the onUndo/onRedo callback - if (!redo) { - const command = app.stack[app.pointer]; - modApp.pointer -= 1; - app.applyPatch(command.before, 'undo'); - return; - // eslint-disable-next-line no-else-return - } else { - modApp.pointer += 1; - const command = app.stack[app.pointer]; - app.applyPatch(command.after, 'redo'); - return; - } - } - const deletedShapes = []; - Object.entries(changedShapes) - .forEach(([id, shape]) => { - if (!shape) deletedShapes.push(id); - else { - // checks to find any bindings assosiated with the changed shapes. - // If any, they may need to be updated as well. - const pageBindings = app.page.bindings; - if (pageBindings) { - Object.entries(pageBindings).forEach(([, b]) => { - if (b.toId.includes(id)) { - const boundShape = app.getShape(b.fromId); - if (shapes[b.fromId] && !isEqual(boundShape, shapes[b.fromId])) { - const shapeBounds = app.getShapeBounds(b.fromId); - boundShape.size = [shapeBounds.width, shapeBounds.height]; - persistShape(boundShape, whiteboardId, isModerator); - } - } - }); - } - let modShape = shape; - if (!shape.id) { - // check it already exists (otherwise we need the full shape) - if (!shapes[id]) { - modShape = app.getShape(id); - } - modShape.id = id; - } - const shapeBounds = app.getShapeBounds(id); - const size = [shapeBounds.width, shapeBounds.height]; - if (!shapes[id] || (shapes[id] && !isEqual(shapes[id].size, size))) { - modShape.size = size; - } - if (!shapes[id] || (shapes[id] && !shapes[id].userId)) { - modShape.userId = currentUser?.userId; - } - // do not change moderator status for existing shapes - if (shapes[id]) { - isModerator = shapes[id].isModerator; - } - persistShape(modShape, whiteboardId, isModerator); - } - }); - - // order the ids of shapes being deleted to prevent crash - // when removing a group shape before its children - const orderedDeletedShapes = []; - deletedShapes.forEach((eid) => { - if (shapes[eid]?.type !== 'group') { - orderedDeletedShapes.unshift(eid); - } else { - orderedDeletedShapes.push(eid); - } - }); - - if (orderedDeletedShapes.length > 0) { - removeShapes(orderedDeletedShapes, whiteboardId); - } -}; - // map different localeCodes from bbb to tldraw const mapLanguage = (language) => { // bbb has xx-xx but in tldraw it's only xx @@ -276,10 +163,10 @@ const getTextSize = (text, style, padding) => { }; const Utils = { - usePrevious, findRemoved, filterInvalidShapes, mapLanguage, sendShapeChanges, getTextSize, + usePrevious, findRemoved, filterInvalidShapes, mapLanguage, getTextSize, }; export default Utils; export { - usePrevious, findRemoved, filterInvalidShapes, mapLanguage, sendShapeChanges, getTextSize, + usePrevious, findRemoved, filterInvalidShapes, mapLanguage, getTextSize, }; From 98b6ef360a7ead0cfb5ba1c549ead9c80d366602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Mon, 15 Jan 2024 16:49:09 -0300 Subject: [PATCH 0219/1039] Refactor: migrate pads/shared notes from Meteor to GraphQL --- .../imports/ui/components/app/component.jsx | 2 +- .../imports/ui/components/app/container.jsx | 10 +- .../ui/components/nav-bar/container.jsx | 5 +- .../imports/ui/components/notes/container.jsx | 5 +- .../notes/notes-graphql/component.tsx | 272 ++++++++++++++++++ .../notes-graphql/hooks/useHasPermission.ts | 25 ++ .../notes-graphql/hooks/useHasUnreadNotes.ts | 13 + .../notes-graphql/hooks/useNotesLastRev.ts | 16 ++ .../notes/notes-graphql/mutations.ts | 14 + .../notes-dropdown/component.tsx | 144 ++++++++++ .../notes-graphql/notes-dropdown/service.ts | 66 +++++ .../components/notes/notes-graphql/queries.ts | 20 ++ .../components/notes/notes-graphql/service.ts | 28 ++ .../components/notes/notes-graphql/styles.ts | 37 +++ .../imports/ui/components/pads/container.jsx | 5 +- .../pads/pads-graphql/component.tsx | 112 ++++++++ .../pads/pads-graphql/content/component.tsx | 61 ++++ .../pads/pads-graphql/content/patch.d.ts | 3 + .../pads/pads-graphql/content/queries.ts | 27 ++ .../pads/pads-graphql/content/styles.ts | 54 ++++ .../pads/pads-graphql/hooks/useRev.ts | 37 +++ .../components/pads/pads-graphql/mutations.ts | 13 + .../components/pads/pads-graphql/queries.ts | 40 +++ .../components/pads/pads-graphql/service.ts | 56 ++++ .../pads/pads-graphql/sessions/component.tsx | 17 ++ .../pads/pads-graphql/sessions/queries.ts | 29 ++ .../pads/pads-graphql/sessions/service.ts | 15 + .../ui/components/pads/pads-graphql/styles.ts | 49 ++++ .../user-notes/container.jsx | 5 +- .../user-notes/component.tsx | 226 +++++++++++++++ .../user-list-content/user-notes/styles.ts | 56 ++++ .../utils/hooks/{index.js => index.ts} | 6 +- 32 files changed, 1455 insertions(+), 13 deletions(-) create mode 100644 bigbluebutton-html5/imports/ui/components/notes/notes-graphql/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/notes/notes-graphql/hooks/useHasPermission.ts create mode 100644 bigbluebutton-html5/imports/ui/components/notes/notes-graphql/hooks/useHasUnreadNotes.ts create mode 100644 bigbluebutton-html5/imports/ui/components/notes/notes-graphql/hooks/useNotesLastRev.ts create mode 100644 bigbluebutton-html5/imports/ui/components/notes/notes-graphql/mutations.ts create mode 100644 bigbluebutton-html5/imports/ui/components/notes/notes-graphql/notes-dropdown/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/notes/notes-graphql/notes-dropdown/service.ts create mode 100644 bigbluebutton-html5/imports/ui/components/notes/notes-graphql/queries.ts create mode 100644 bigbluebutton-html5/imports/ui/components/notes/notes-graphql/service.ts create mode 100644 bigbluebutton-html5/imports/ui/components/notes/notes-graphql/styles.ts create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/patch.d.ts create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/queries.ts create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/styles.ts create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/hooks/useRev.ts create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/mutations.ts create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/queries.ts create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/service.ts create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/sessions/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/sessions/queries.ts create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/sessions/service.ts create mode 100644 bigbluebutton-html5/imports/ui/components/pads/pads-graphql/styles.ts create mode 100644 bigbluebutton-html5/imports/ui/components/user-list/user-list-graphql/user-list-content/user-notes/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/user-list/user-list-graphql/user-list-content/user-notes/styles.ts rename bigbluebutton-html5/imports/ui/components/utils/hooks/{index.js => index.ts} (70%) diff --git a/bigbluebutton-html5/imports/ui/components/app/component.jsx b/bigbluebutton-html5/imports/ui/components/app/component.jsx index 760ed7885e..9ca8faa558 100644 --- a/bigbluebutton-html5/imports/ui/components/app/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/app/component.jsx @@ -12,7 +12,7 @@ import UserInfoContainer from '/imports/ui/components/user-info/container'; import BreakoutRoomInvitation from '/imports/ui/components/breakout-room/invitation/container'; import { Meteor } from 'meteor/meteor'; import ToastContainer from '/imports/ui/components/common/toast/container'; -import PadsSessionsContainer from '/imports/ui/components/pads/sessions/container'; +import PadsSessionsContainer from '/imports/ui/components/pads/pads-graphql/sessions/component'; import WakeLockContainer from '../wake-lock/container'; import NotificationsBarContainer from '../notifications-bar/container'; import AudioContainer from '../audio/container'; diff --git a/bigbluebutton-html5/imports/ui/components/app/container.jsx b/bigbluebutton-html5/imports/ui/components/app/container.jsx index 12caa61136..885ba0c47b 100755 --- a/bigbluebutton-html5/imports/ui/components/app/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/app/container.jsx @@ -25,7 +25,7 @@ import { isEqual } from 'radash'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; import useMeeting from '/imports/ui/core/hooks/useMeeting'; import { LAYOUT_TYPE } from '/imports/ui/components/layout/enums'; -import { useMutation } from '@apollo/client'; +import { useMutation, useSubscription } from '@apollo/client'; import { SET_MOBILE_FLAG } from '/imports/ui/core/graphql/mutations/userMutations'; import { @@ -34,8 +34,10 @@ import { } from './service'; import App from './component'; +import { PINNED_PAD_SUBSCRIPTION } from '../notes/notes-graphql/queries'; const CUSTOM_STYLE_URL = Meteor.settings.public.app.customStyleUrl; +const NOTES_CONFIG = Meteor.settings.public.notes; const endMeeting = (code, ejectedReason) => { Session.set('codeError', code); @@ -61,7 +63,6 @@ const AppContainer = (props) => { pushLayoutMeeting, currentUserId, shouldShowScreenshare: propsShouldShowScreenshare, - shouldShowSharedNotes, presentationRestoreOnUpdate, randomlySelectedUser, isModalOpen, @@ -88,6 +89,9 @@ const AppContainer = (props) => { const layoutContextDispatch = layoutDispatch(); const [setMobileFlag] = useMutation(SET_MOBILE_FLAG); + const { data: pinnedPadData } = useSubscription(PINNED_PAD_SUBSCRIPTION); + const shouldShowSharedNotes = !!pinnedPadData + && pinnedPadData.sharedNotes[0]?.sharedNotesExtId === NOTES_CONFIG.id; const setMobileUser = (mobile) => { setMobileFlag({ @@ -311,7 +315,6 @@ export default withTracker(() => { const AppSettings = Settings.application; const { selectedLayout, pushLayout } = AppSettings; const { viewScreenshare } = Settings.dataSaving; - const shouldShowSharedNotes = MediaService.shouldShowSharedNotes(); const shouldShowScreenshare = MediaService.shouldShowScreenshare(); let customStyleUrl = getFromUserSettings('bbb_custom_style_url', false); @@ -352,7 +355,6 @@ export default withTracker(() => { darkTheme: AppSettings.darkTheme, shouldShowScreenshare, viewScreenshare, - shouldShowSharedNotes, isLargeFont: Session.get('isLargeFont'), presentationRestoreOnUpdate: getFromUserSettings( 'bbb_force_restore_presentation_on_new_events', diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/container.jsx b/bigbluebutton-html5/imports/ui/components/nav-bar/container.jsx index 3745cf2f16..838213fbd5 100755 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/container.jsx @@ -15,6 +15,7 @@ import { layoutSelectInput, layoutSelectOutput, layoutDispatch } from '../layout import { PluginsContext } from '/imports/ui/components/components-data/plugin-context/context'; import { PANELS } from '/imports/ui/components/layout/enums'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import useHasUnreadNotes from '../notes/notes-graphql/hooks/useHasUnreadNotes'; const PUBLIC_CONFIG = Meteor.settings.public; @@ -38,7 +39,7 @@ const NavBarContainer = ({ children, ...props }) => { const { users } = usingUsersContext; const { groupChat: groupChats } = usingGroupChatContext; const activeChats = userListService.getActiveChats({ groupChatsMessages, groupChats, users:users[Auth.meetingID] }); - const { unread, ...rest } = props; + const unread = useHasUnreadNotes(); const sidebarContent = layoutSelectInput((i) => i.sidebarContent); const sidebarNavigation = layoutSelectInput((i) => i.sidebarNavigation); @@ -88,7 +89,7 @@ const NavBarContainer = ({ children, ...props }) => { activeChats, currentUserId: Auth.userID, pluginNavBarItems, - ...rest, + ...props, }} style={{ ...navBar }} > diff --git a/bigbluebutton-html5/imports/ui/components/notes/container.jsx b/bigbluebutton-html5/imports/ui/components/notes/container.jsx index 25bcc72a0b..0c16705ce7 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/notes/container.jsx @@ -5,6 +5,7 @@ import Service from './service'; import MediaService from '/imports/ui/components/media/service'; import { layoutSelectInput, layoutDispatch, layoutSelectOutput } from '../layout/context'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import NotesContainerGraphql from './notes-graphql/component'; const Container = ({ ...props }) => { const cameraDock = layoutSelectInput((i) => i.cameraDock); @@ -27,7 +28,7 @@ const Container = ({ ...props }) => { }} />; }; -export default withTracker(() => { +withTracker(() => { const hasPermission = Service.hasPermission(); const isRTL = document.documentElement.getAttribute('dir') === 'rtl'; const shouldShowSharedNotesOnPresentationArea = MediaService.shouldShowSharedNotes(); @@ -37,3 +38,5 @@ export default withTracker(() => { shouldShowSharedNotesOnPresentationArea, }; })(Container); + +export default NotesContainerGraphql; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/component.tsx new file mode 100644 index 0000000000..9060bb626b --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/component.tsx @@ -0,0 +1,272 @@ +import React, { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useMutation, useSubscription } from '@apollo/client'; +import { Meteor } from 'meteor/meteor'; +import { Session } from 'meteor/session'; +import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component'; +import Service from '/imports/ui/components/notes/service'; +import PadContainer from '/imports/ui/components/pads/container'; +import browserInfo from '/imports/utils/browserInfo'; +import Header from '/imports/ui/components/common/control-header/component'; +import NotesDropdown from './notes-dropdown/component'; +import { PANELS, ACTIONS, LAYOUT_TYPE } from '/imports/ui/components/layout/enums'; +import { isPresentationEnabled } from '/imports/ui/services/features'; +import { layoutSelectInput, layoutDispatch, layoutSelectOutput } from '/imports/ui/components/layout/context'; +import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import useHasPermission from './hooks/useHasPermission'; +import Styled from './styles'; +import { PINNED_PAD_SUBSCRIPTION, PinnedPadSubscriptionResponse } from './queries'; +import { PIN_NOTES } from './mutations'; + +const CHAT_CONFIG = Meteor.settings.public.chat; +const NOTES_CONFIG = Meteor.settings.public.notes; +const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id; +const DELAY_UNMOUNT_SHARED_NOTES = Meteor.settings.public.app.delayForUnmountOfSharedNote; + +const intlMessages = defineMessages({ + hide: { + id: 'app.notes.hide', + description: 'Label for hiding shared notes button', + }, + title: { + id: 'app.notes.title', + description: 'Title for the shared notes', + }, + unpinNotes: { + id: 'app.notes.notesDropdown.unpinNotes', + description: 'Label for unpin shared notes button', + }, +}); + +interface NotesContainerGraphqlProps { + area: 'media' | undefined; + layoutType: string; + isToSharedNotesBeShow: boolean; +} + +interface NotesGraphqlProps extends NotesContainerGraphqlProps { + hasPermission: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + layoutContextDispatch: (action: any) => void; + isResizing: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sidebarContent: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sharedNotesOutput: any; + amIPresenter: boolean; + isRTL: boolean; + shouldShowSharedNotesOnPresentationArea: boolean; + handlePinSharedNotes: (pinned: boolean) => void; +} + +let timoutRef: NodeJS.Timeout | undefined; +const sidebarContentToIgnoreDelay = ['captions']; + +const NotesGraphql: React.FC = (props) => { + const { + hasPermission, + isRTL, + layoutContextDispatch, + isResizing, + area, + layoutType, + sidebarContent, + sharedNotesOutput, + amIPresenter, + isToSharedNotesBeShow, + shouldShowSharedNotesOnPresentationArea, + handlePinSharedNotes, + } = props; + const [shouldRenderNotes, setShouldRenderNotes] = useState(false); + const intl = useIntl(); + + const { isChrome } = browserInfo; + const isOnMediaArea = area === 'media'; + const style = isOnMediaArea ? { + position: 'absolute', + ...sharedNotesOutput, + } : {}; + + const isHidden = (isOnMediaArea && (style.width === 0 || style.height === 0)) + || (!isToSharedNotesBeShow + && !sidebarContentToIgnoreDelay.includes(sidebarContent.sidebarContentPanel)) + || shouldShowSharedNotesOnPresentationArea; + + if (isHidden && !isOnMediaArea) { + style.padding = 0; + style.display = 'none'; + } + useEffect(() => { + if (isToSharedNotesBeShow) { + setShouldRenderNotes(true); + clearTimeout(timoutRef); + } else { + timoutRef = setTimeout(() => { + setShouldRenderNotes(false); + }, (sidebarContentToIgnoreDelay.includes(sidebarContent.sidebarContentPanel) + || shouldShowSharedNotesOnPresentationArea) + ? 0 : DELAY_UNMOUNT_SHARED_NOTES); + } + return () => clearTimeout(timoutRef); + }, [isToSharedNotesBeShow, sidebarContent.sidebarContentPanel]); + // eslint-disable-next-line consistent-return + useEffect(() => { + if ( + isOnMediaArea + && (sidebarContent.isOpen || !isPresentationEnabled()) + && (sidebarContent.sidebarContentPanel === PANELS.SHARED_NOTES || !isPresentationEnabled()) + ) { + if (layoutType === LAYOUT_TYPE.VIDEO_FOCUS) { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.CHAT, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_ID_CHAT_OPEN, + value: PUBLIC_CHAT_ID, + }); + } else { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: false, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.NONE, + }); + } + + layoutContextDispatch({ + type: ACTIONS.SET_NOTES_IS_PINNED, + value: true, + }); + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_IS_OPEN, + value: true, + }); + + return () => { + layoutContextDispatch({ + type: ACTIONS.SET_NOTES_IS_PINNED, + value: false, + }); + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_IS_OPEN, + value: Session.get('presentationLastState'), + }); + }; + } if (shouldShowSharedNotesOnPresentationArea) { + layoutContextDispatch({ + type: ACTIONS.SET_NOTES_IS_PINNED, + value: true, + }); + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_IS_OPEN, + value: true, + }); + } + }, []); + + const renderHeaderOnMedia = () => { + return amIPresenter ? ( + { + handlePinSharedNotes(false); + }, + }} + /> + ) : null; + }; + + return (shouldRenderNotes || shouldShowSharedNotesOnPresentationArea) && ( + + {!isOnMediaArea ? ( + // @ts-ignore Until everything in Typescript +
{ + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: false, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.NONE, + }); + }, + 'data-test': 'hideNotesLabel', + 'aria-label': intl.formatMessage(intlMessages.hide), + label: intl.formatMessage(intlMessages.title), + }} + customRightButton={ + + } + /> + ) : renderHeaderOnMedia()} + + + ); +}; + +const NotesContainerGraphql: React.FC = (props) => { + const { area, layoutType, isToSharedNotesBeShow } = props; + + const hasPermission = useHasPermission(); + const { data: pinnedPadData } = useSubscription(PINNED_PAD_SUBSCRIPTION); + const { data: currentUserData } = useCurrentUser((user) => ({ + presenter: user.presenter, + })); + const [pinSharedNotes] = useMutation(PIN_NOTES); + + // @ts-ignore Until everything in Typescript + const cameraDock = layoutSelectInput((i) => i.cameraDock); + // @ts-ignore Until everything in Typescript + const sharedNotesOutput = layoutSelectOutput((i) => i.sharedNotes); + // @ts-ignore Until everything in Typescript + const sidebarContent = layoutSelectInput((i) => i.sidebarContent); + const { isResizing } = cameraDock; + const layoutContextDispatch = layoutDispatch(); + const amIPresenter = !!currentUserData?.presenter; + + const isRTL = document.documentElement.getAttribute('dir') === 'rtl'; + const shouldShowSharedNotesOnPresentationArea = !!pinnedPadData + && pinnedPadData.sharedNotes[0]?.sharedNotesExtId === NOTES_CONFIG.id; + + const handlePinSharedNotes = (pinned: boolean) => { + pinSharedNotes({ variables: { pinned } }); + }; + + return ( + + ); +}; + +export default injectWbResizeEvent(NotesContainerGraphql); diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/hooks/useHasPermission.ts b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/hooks/useHasPermission.ts new file mode 100644 index 0000000000..e1b46854ee --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/hooks/useHasPermission.ts @@ -0,0 +1,25 @@ +import { Meteor } from 'meteor/meteor'; +import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import useMeeting from '/imports/ui/core/hooks/useMeeting'; + +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; + +const useHasPermission = () => { + const { data: currentUserData } = useCurrentUser((u) => ({ + locked: u.locked, + role: u.role, + })); + const { data: meetingData } = useMeeting((m) => ({ + lockSettings: m.lockSettings, + })); + + if (currentUserData?.role === ROLE_MODERATOR) return true; + + if (currentUserData?.locked) { + return !meetingData?.lockSettings?.disableNotes; + } + + return true; +}; + +export default useHasPermission; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/hooks/useHasUnreadNotes.ts b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/hooks/useHasUnreadNotes.ts new file mode 100644 index 0000000000..92b270e271 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/hooks/useHasUnreadNotes.ts @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; +import useRev from '/imports/ui/components/pads/pads-graphql/hooks/useRev'; +import useNotesLastRev from './useNotesLastRev'; + +const NOTES_CONFIG = Meteor.settings.public.notes; + +const useHasUnreadNotes = () => { + const { lastRev } = useNotesLastRev(); + const rev = useRev(NOTES_CONFIG.id); + return rev > lastRev; +}; + +export default useHasUnreadNotes; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/hooks/useNotesLastRev.ts b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/hooks/useNotesLastRev.ts new file mode 100644 index 0000000000..6fbeac3697 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/hooks/useNotesLastRev.ts @@ -0,0 +1,16 @@ +import { useCallback } from 'react'; +import { makeVar, useReactiveVar } from '@apollo/client'; + +const notesLastRev = makeVar(0); + +const useNotesLastRev = () => { + const lastRev = useReactiveVar(notesLastRev); + const setNotesLastRev = useCallback((rev: number) => notesLastRev(rev), []); + + return { + lastRev, + setNotesLastRev, + }; +}; + +export default useNotesLastRev; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/mutations.ts b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/mutations.ts new file mode 100644 index 0000000000..cec64f8402 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/mutations.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const PIN_NOTES = gql` + mutation pinNotes($pinned: Boolean!) { + sharedNotesSetPinned( + sharedNotesExtId: notes, + pinned: $pinned + ) + } +`; + +export default { + PIN_NOTES, +}; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/notes-dropdown/component.tsx b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/notes-dropdown/component.tsx new file mode 100644 index 0000000000..b948c7c9b8 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/notes-dropdown/component.tsx @@ -0,0 +1,144 @@ +import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { Meteor } from 'meteor/meteor'; +import BBBMenu from '/imports/ui/components/common/menu/component'; +import Trigger from '/imports/ui/components/common/control-header/right/component'; +import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import { uniqueId } from '/imports/utils/string-utils'; +import { layoutSelect } from '/imports/ui/components/layout/context'; +import { useSubscription } from '@apollo/client'; +import { PROCESSED_PRESENTATIONS_SUBSCRIPTION } from '/imports/ui/components/whiteboard/queries'; +import Service from './service'; + +const DEBOUNCE_TIMEOUT = 15000; +const NOTES_CONFIG = Meteor.settings.public.notes; +const NOTES_IS_PINNABLE = NOTES_CONFIG.pinnable; + +const intlMessages = defineMessages({ + convertAndUploadLabel: { + id: 'app.notes.notesDropdown.covertAndUpload', + description: 'Export shared notes as a PDF and upload to the main room', + }, + pinNotes: { + id: 'app.notes.notesDropdown.pinNotes', + description: 'Label for pin shared notes button', + }, + options: { + id: 'app.notes.notesDropdown.notesOptions', + description: 'Label for shared notes options', + }, +}); + +interface NotesDropdownContainerGraphqlProps { + handlePinSharedNotes: (pinned: boolean) => void +} + +interface NotesDropdownGraphqlProps extends NotesDropdownContainerGraphqlProps { + amIPresenter: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + presentations: any; + isRTL: boolean; +} + +const NotesDropdownGraphql: React.FC = (props) => { + const { + amIPresenter, presentations, handlePinSharedNotes, isRTL, + } = props; + const [converterButtonDisabled, setConverterButtonDisabled] = useState(false); + const intl = useIntl(); + + const getAvailableActions = () => { + const uploadIcon = 'upload'; + const pinIcon = 'presentation'; + + const menuItems = []; + + if (amIPresenter) { + menuItems.push( + { + key: uniqueId('notes-option-'), + icon: uploadIcon, + dataTest: 'moveNotesToWhiteboard', + label: intl.formatMessage(intlMessages.convertAndUploadLabel), + disabled: converterButtonDisabled, + onClick: () => { + setConverterButtonDisabled(true); + setTimeout(() => setConverterButtonDisabled(false), DEBOUNCE_TIMEOUT); + return Service.convertAndUpload(presentations); + }, + }, + ); + } + + if (amIPresenter && NOTES_IS_PINNABLE) { + menuItems.push( + { + key: uniqueId('notes-option-'), + icon: pinIcon, + dataTest: 'pinNotes', + label: intl.formatMessage(intlMessages.pinNotes), + onClick: () => { + handlePinSharedNotes(true); + }, + }, + ); + } + + return menuItems; + }; + + const actions = getAvailableActions(); + + if (actions.length === 0) return null; + + return ( + <> + null} + /> + )} + opts={{ + id: 'notes-options-dropdown', + keepMounted: true, + transitionDuration: 0, + elevation: 3, + getcontentanchorel: null, + fullwidth: 'true', + anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' }, + transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' }, + }} + actions={actions} + /> + + ); +}; + +const NotesDropdownContainerGraphql: React.FC = (props) => { + const { handlePinSharedNotes } = props; + const { data: currentUserData } = useCurrentUser((user) => ({ + presenter: user.presenter, + })); + const amIPresenter = !!currentUserData?.presenter; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isRTL = layoutSelect((i: any) => i.isRTL); + + const { data: presentationData } = useSubscription(PROCESSED_PRESENTATIONS_SUBSCRIPTION); + const presentations = presentationData?.pres_presentation || []; + + return ( + + ); +}; + +export default NotesDropdownContainerGraphql; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/notes-dropdown/service.ts b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/notes-dropdown/service.ts new file mode 100644 index 0000000000..4e749db4ad --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/notes-dropdown/service.ts @@ -0,0 +1,66 @@ +import { Meteor } from 'meteor/meteor'; +import Auth from '/imports/ui/services/auth'; +import PresentationUploaderService from '/imports/ui/components/presentation/presentation-uploader/service'; +import PadsService from '/imports/ui/components/pads/service'; +import NotesService from '/imports/ui/components/notes/service'; +import { UploadingPresentations } from '/imports/api/presentations'; +import { uniqueId } from '/imports/utils/string-utils'; + +const PADS_CONFIG = Meteor.settings.public.pads; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function convertAndUpload(presentations: any) { + let filename = 'Shared_Notes'; + const duplicates = presentations.filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (pres: any) => pres.filename?.startsWith(filename) || pres.name?.startsWith(filename), + ).length; + + if (duplicates !== 0) { filename = `${filename}(${duplicates})`; } + + const params = PadsService.getParams(); + const padId = await PadsService.getPadId(NotesService.ID); + const extension = 'pdf'; + filename = `${filename}.${extension}`; + + UploadingPresentations.insert({ + id: uniqueId(filename), + progress: 0, + filename, + lastModifiedUploader: false, + upload: { + done: false, + error: false, + }, + uploadTimestamp: new Date(), + }); + + const exportUrl = Auth.authenticateURL(`${PADS_CONFIG.url}/p/${padId}/export/${extension}?${params}`); + const sharedNotesAsFile = await fetch(exportUrl, { credentials: 'include' }); + + const data = await sharedNotesAsFile.blob(); + + const sharedNotesData = new File([data], filename, { + type: data.type, + }); + + PresentationUploaderService.handleSavePresentation([], false, { + file: sharedNotesData, + isDownloadable: false, // by default new presentations are set not to be downloadable + isRemovable: true, + filename: sharedNotesData.name, + isCurrent: true, + conversion: { done: false, error: false }, + upload: { done: false, error: false, progress: 0 }, + exportation: { isRunning: false, error: false }, + onConversion: () => { }, + onUpload: () => { }, + onProgress: () => { }, + onDone: () => { }, + }); +} + +export default { + convertAndUpload, + pinSharedNotes: () => NotesService.pinSharedNotes(true), +}; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/queries.ts b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/queries.ts new file mode 100644 index 0000000000..a34c648078 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/queries.ts @@ -0,0 +1,20 @@ +import { gql } from '@apollo/client'; + +export interface PinnedPadSubscriptionResponse { + sharedNotes: Array<{ + pinned: boolean; + sharedNotesExtId: string; + }>; +} + +export const PINNED_PAD_SUBSCRIPTION = gql` + subscription isSharedNotesPinned { + sharedNotes(where: { pinned: { _eq: true } }) { + pinned + sharedNotesExtId + model + } + } +`; + +export default { PINNED_PAD_SUBSCRIPTION }; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/service.ts b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/service.ts new file mode 100644 index 0000000000..fccb0f23b0 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/service.ts @@ -0,0 +1,28 @@ +import { Meteor } from 'meteor/meteor'; +import { ACTIONS, PANELS } from '/imports/ui/components/layout/enums'; +import { isSharedNotesEnabled } from '/imports/ui/services/features'; + +const NOTES_CONFIG = Meteor.settings.public.notes; + +const isEnabled = () => isSharedNotesEnabled(); + +// @ts-ignore Until everything in Typescript +const toggleNotesPanel = (sidebarContentPanel, layoutContextDispatch) => { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: sidebarContentPanel !== PANELS.SHARED_NOTES, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: + sidebarContentPanel === PANELS.SHARED_NOTES + ? PANELS.NONE + : PANELS.SHARED_NOTES, + }); +}; + +export default { + ID: NOTES_CONFIG.id, + toggleNotesPanel, + isEnabled, +}; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/styles.ts b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/styles.ts new file mode 100644 index 0000000000..e6737c39e3 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-graphql/styles.ts @@ -0,0 +1,37 @@ +import styled from 'styled-components'; +import { + mdPaddingX, +} from '/imports/ui/stylesheets/styled-components/general'; +import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette'; +import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; +import CommonHeader from '/imports/ui/components/common/control-header/component'; + +const Notes = styled.div<{ isChrome: boolean }>` + background-color: ${colorWhite}; + padding: ${mdPaddingX}; + display: flex; + flex-grow: 1; + flex-direction: column; + overflow: hidden; + height: 100%; + + ${({ isChrome }) => isChrome && ` + transform: translateZ(0); + `} + + @media ${smallOnly} { + transform: none !important; + &.no-padding { + padding: 0; + } + } +`; + +const Header = styled(CommonHeader)` + padding-bottom: .2rem; +`; + +export default { + Notes, + Header, +}; diff --git a/bigbluebutton-html5/imports/ui/components/pads/container.jsx b/bigbluebutton-html5/imports/ui/components/pads/container.jsx index d617d5d9ab..cbaecfc692 100644 --- a/bigbluebutton-html5/imports/ui/components/pads/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/pads/container.jsx @@ -3,10 +3,11 @@ import { withTracker } from 'meteor/react-meteor-data'; import Pad from './component'; import Service from './service'; import SessionsService from './sessions/service'; +import PadContainerGraphql from './pads-graphql/component'; const Container = ({ ...props }) => ; -export default withTracker((props) => { +withTracker((props) => { const { externalId, hasPermission, @@ -23,3 +24,5 @@ export default withTracker((props) => { hasSession, }; })(Container); + +export default PadContainerGraphql; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/component.tsx new file mode 100644 index 0000000000..0990ca3ea9 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/component.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useMutation, useSubscription } from '@apollo/client'; +import { HAS_PAD_SUBSCRIPTION, HasPadSubscriptionResponse } from './queries'; +import { PAD_SESSION_SUBSCRIPTION, PadSessionSubscriptionResponse } from './sessions/queries'; +import { CREATE_SESSION } from './mutations'; +import Service from './service'; +import Styled from './styles'; +import PadContent from './content/component'; + +const intlMessages = defineMessages({ + hint: { + id: 'app.pads.hint', + description: 'Label for hint on how to escape iframe', + }, +}); + +interface PadContainerGraphqlProps { + externalId: string; + hasPermission: boolean; + isResizing: boolean; + isRTL: boolean; +} + +interface PadGraphqlProps extends Omit { + hasSession: boolean; + sessionIds: Array; + padId: string | undefined; +} + +const PadGraphql: React.FC = (props) => { + const { + externalId, + hasSession, + isResizing, + isRTL, + sessionIds, + padId, + } = props; + const [padURL, setPadURL] = useState(); + const intl = useIntl(); + + useEffect(() => { + if (!padId) { + setPadURL(undefined); + return; + } + setPadURL(Service.buildPadURL(padId, sessionIds)); + }, [isRTL, hasSession]); + + if (!hasSession) { + return ; + } + + return ( + + + + {intl.formatMessage(intlMessages.hint)} + + + ); +}; + +const PadContainerGraphql: React.FC = (props) => { + const { + externalId, + hasPermission, + isRTL, + isResizing, + } = props; + + const { data: hasPadData } = useSubscription( + HAS_PAD_SUBSCRIPTION, + { variables: { externalId } }, + ); + const { data: padSessionData } = useSubscription(PAD_SESSION_SUBSCRIPTION); + const [createSession] = useMutation(CREATE_SESSION); + + const sessionData = padSessionData?.sharedNotes_session ?? []; + const session = sessionData.find((s) => s.sharedNotesExtId === externalId); + const hasPad = !!hasPadData && hasPadData.sharedNotes.length > 0; + const hasSession = !!session?.sessionId; + const sessionIds = new Set(sessionData.map((s) => s.sessionId)); + + if (hasPad && !hasSession && hasPermission) { + createSession({ variables: { externalId } }); + } + + return ( + + ); +}; + +export default PadContainerGraphql; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/component.tsx b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/component.tsx new file mode 100644 index 0000000000..508101c38d --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/component.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from 'react'; +import { useSubscription } from '@apollo/client'; +import { patch } from '@mconf/bbb-diff'; +import Styled from './styles'; +import { GET_PAD_CONTENT_DIFF_STREAM, GetPadContentDiffStreamResponse } from './queries'; + +interface PadContentProps { + content: string; +} + +interface PadContentContainerProps { + externalId: string; +} + +const PadContent: React.FC = ({ + content, +}) => { + const contentSplit = content.split(''); + const contentStyle = ` + + + `; + const contentWithStyle = [contentSplit[0], contentStyle, contentSplit[1]].join(''); + return ( + + + + ); +}; + +const PadContentContainer: React.FC = ({ externalId }) => { + const [content, setContent] = useState(''); + const { data: contentDiffData } = useSubscription( + GET_PAD_CONTENT_DIFF_STREAM, + { variables: { externalId } }, + ); + + useEffect(() => { + if (!contentDiffData) return; + const patches = contentDiffData.sharedNotes_diff_stream; + const patchedContent = patches.reduce((currentContent, attribs) => patch( + currentContent, + { start: attribs.start, end: attribs.end, text: attribs.diff }, + ), content); + setContent(patchedContent); + }, [contentDiffData]); + + return ( + + ); +}; + +export default PadContentContainer; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/patch.d.ts b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/patch.d.ts new file mode 100644 index 0000000000..050a3a370b --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/patch.d.ts @@ -0,0 +1,3 @@ +declare module '@mconf/bbb-diff' { + declare function patch (prevText: string, attribs: { start: number, end: number, text: string }): string; +} diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/queries.ts b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/queries.ts new file mode 100644 index 0000000000..eef7cbd01e --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/queries.ts @@ -0,0 +1,27 @@ +import { gql } from '@apollo/client'; + +export interface GetPadContentDiffStreamResponse { + sharedNotes_diff_stream: Array<{ + start: number; + end: number; + diff: string; + }>; +} + +export const GET_PAD_CONTENT_DIFF_STREAM = gql` + subscription GetPadContentDiffStream($externalId: String!) { + sharedNotes_diff_stream( + batch_size: 10, + cursor: { initial_value: { rev: 0 } }, + where: { sharedNotesExtId: { _eq: $externalId } } + ) { + start + end + diff + } + } +`; + +export default { + GET_PAD_CONTENT_DIFF_STREAM, +}; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/styles.ts b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/styles.ts new file mode 100644 index 0000000000..90e698fdc1 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/content/styles.ts @@ -0,0 +1,54 @@ +import styled from 'styled-components'; +import { + colorGray, + colorGrayLightest, +} from '/imports/ui/stylesheets/styled-components/palette'; + +const Wrapper = styled.div` + display: flex; + height: 100%; + position: relative; + width: 100%; +`; + +const contentText = ` +font-family: Verdana, Arial, Helvetica, sans-serif; +font-size: 15px; +color: ${colorGray}; +bottom: 0; +box-sizing: border-box; +display: block; +overflow-x: hidden; +overflow-wrap: break-word; +overflow-y: auto; +padding-top: 1rem; +position: absolute; +right: 0; +left:0; +top: 0; +white-space: normal; + + +[dir="ltr"] & { + padding-left: 1rem; + padding-right: .5rem; +} + +[dir="rtl"] & { + padding-left: .5rem; + padding-right: 1rem; +} +`; + +const Iframe = styled.iframe` + border-width: 0; + width: 100%; + border-top: 1px solid ${colorGrayLightest}; + border-bottom: 1px solid ${colorGrayLightest}; +`; + +export default { + Wrapper, + Iframe, + contentText, +}; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/hooks/useRev.ts b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/hooks/useRev.ts new file mode 100644 index 0000000000..27bceca530 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/hooks/useRev.ts @@ -0,0 +1,37 @@ +import { gql, useSubscription } from '@apollo/client'; +import { useEffect, useState } from 'react'; + +interface GetPadLastRevResponse { + sharedNotes: Array<{ + lastRev: number; + }>; +} + +const GET_PAD_LAST_REV = gql` + subscription GetPadLastRev($externalId: String!) { + sharedNotes( + where: { sharedNotesExtId: { _eq: $externalId } } + ) { + lastRev + } + } +`; + +const useRev = (externalId: string) => { + const [rev, setRev] = useState(0); + const { data: padRevData } = useSubscription( + GET_PAD_LAST_REV, + { variables: { externalId } }, + ); + + useEffect(() => { + if (!padRevData) return; + const pad = padRevData.sharedNotes[0]; + if (!pad) return; + setRev(pad.lastRev); + }, [padRevData]); + + return rev; +}; + +export default useRev; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/mutations.ts b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/mutations.ts new file mode 100644 index 0000000000..878adcbf5b --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/mutations.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const CREATE_SESSION = gql` + mutation createSession($externalId: String!) { + sharedNotesCreateSession( + sharedNotesExtId: $externalId + ) + } +`; + +export default { + CREATE_SESSION, +}; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/queries.ts b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/queries.ts new file mode 100644 index 0000000000..edba939c7d --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/queries.ts @@ -0,0 +1,40 @@ +import { gql } from '@apollo/client'; + +export interface HasPadSubscriptionResponse { + sharedNotes: Array<{ + sharedNotesExtId: string; + }>; +} + +export interface GetPadIdQueryResponse { + sharedNotes: Array<{ + padId: string; + sharedNotesExtId: string; + }>; +} + +export const HAS_PAD_SUBSCRIPTION = gql` + subscription hasPad($externalId: String!) { + sharedNotes( + where: { sharedNotesExtId: { _eq: $externalId } } + ) { + sharedNotesExtId + } + } +`; + +export const GET_PAD_ID = gql` + query getPadId($externalId: String!) { + sharedNotes( + where: { sharedNotesExtId: { _eq: $externalId } } + ) { + padId + sharedNotesExtId + } + } +`; + +export default { + HAS_PAD_SUBSCRIPTION, + GET_PAD_ID, +}; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/service.ts b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/service.ts new file mode 100644 index 0000000000..89c07efed1 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/service.ts @@ -0,0 +1,56 @@ +import { Meteor } from 'meteor/meteor'; +import { makeCall } from '/imports/ui/services/api'; +import { PadsUpdates } from '/imports/api/pads'; +import Auth from '/imports/ui/services/auth'; +import Settings from '/imports/ui/services/settings'; + +const PADS_CONFIG = Meteor.settings.public.pads; + +const getLang = (): string => { + // @ts-ignore While Meteor in the project + const { locale } = Settings.application; + return locale ? locale.toLowerCase() : ''; +}; + +const getParams = () => { + const config = { + lang: getLang(), + rtl: document.documentElement.getAttribute('dir') === 'rtl', + }; + + const params = Object.keys(config) + .map((key) => `${key}=${encodeURIComponent(config[key as keyof typeof config])}`) + .join('&'); + return params; +}; + +const createGroup = (externalId: string, model: string, name: string) => makeCall('createGroup', externalId, model, name); + +const buildPadURL = (padId: string, sessionIds: Array) => { + const params = getParams(); + const sessionIdsStr = sessionIds.join(','); + const url = Auth.authenticateURL( + `${PADS_CONFIG.url}/auth_session?padName=${padId}&sessionID=${sessionIdsStr}&${params}`, + ); + return url; +}; + +const getPadTail = (externalId: string) => { + const updates = PadsUpdates.findOne( + { + meetingId: Auth.meetingID, + externalId, + }, { fields: { tail: 1 } }, + ); + + if (updates && updates.tail) return updates.tail; + + return ''; +}; + +export default { + createGroup, + buildPadURL, + getPadTail, + getParams, +}; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/sessions/component.tsx b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/sessions/component.tsx new file mode 100644 index 0000000000..d255f002b5 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/sessions/component.tsx @@ -0,0 +1,17 @@ +import { useSubscription } from '@apollo/client'; +import { PAD_SESSION_SUBSCRIPTION, PadSessionSubscriptionResponse } from './queries'; +import Service from './service'; + +const PadSessionContainerGraphql = () => { + const { data: padSessionData } = useSubscription(PAD_SESSION_SUBSCRIPTION); + + if (padSessionData) { + const sessions = new Set(); + padSessionData.sharedNotes_session.forEach((session) => sessions.add(session.sessionId)); + Service.setCookie(Array.from(sessions)); + } + + return null; +}; + +export default PadSessionContainerGraphql; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/sessions/queries.ts b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/sessions/queries.ts new file mode 100644 index 0000000000..b89f3e6767 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/sessions/queries.ts @@ -0,0 +1,29 @@ +import { gql } from '@apollo/client'; + +export interface PadSessionSubscriptionResponse { + sharedNotes_session: Array<{ + sessionId: string; + sharedNotesExtId: string; + padId: string; + sharedNotes: { + padId: string; + }; + }>; +} + +export const PAD_SESSION_SUBSCRIPTION = gql` + subscription padSession { + sharedNotes_session { + sessionId + sharedNotesExtId + padId + sharedNotes { + padId + } + } + } +`; + +export default { + PAD_SESSION_SUBSCRIPTION, +}; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/sessions/service.ts b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/sessions/service.ts new file mode 100644 index 0000000000..9a4176fb9f --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/sessions/service.ts @@ -0,0 +1,15 @@ +import { Meteor } from 'meteor/meteor'; + +const COOKIE_CONFIG = Meteor.settings.public.pads.cookie; +const PATH = COOKIE_CONFIG.path; +const SAME_SITE = COOKIE_CONFIG.sameSite; +const SECURE = COOKIE_CONFIG.secure; + +const setCookie = (sessions: Array) => { + const sessionIds = sessions.join(','); + document.cookie = `sessionID=${sessionIds}; path=${PATH}; SameSite=${SAME_SITE}; ${SECURE ? 'Secure' : ''}`; +}; + +export default { + setCookie, +}; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/styles.ts b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/styles.ts new file mode 100644 index 0000000000..928aa8cb18 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/styles.ts @@ -0,0 +1,49 @@ +import styled from 'styled-components'; +import { + smPaddingX, + lgPaddingY, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + colorGray, + colorGrayLightest, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography'; + +const Hint = styled.span` + visibility: hidden; + position: absolute; + @media (pointer: none) { + visibility: visible; + position: relative; + color: ${colorGray}; + font-size: ${fontSizeSmall}; + font-style: italic; + padding: ${smPaddingX} 0 0 ${smPaddingX}; + text-align: left; + [dir="rtl"] & { + padding-right: ${lgPaddingY} ${lgPaddingY} 0 0; + text-align: right; + } + } +`; + +const Pad = styled.div` + display: flex; + height: 100%; + width: 100%; +`; + +const IFrame = styled.iframe` + width: 100%; + height: auto; + overflow: hidden; + border-style: none; + border-bottom: 1px solid ${colorGrayLightest}; + padding-bottom: 5px; +`; + +export default { + Hint, + Pad, + IFrame, +}; diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-notes/container.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-notes/container.jsx index 670ad19331..1da5883e5e 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-notes/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-notes/container.jsx @@ -4,6 +4,7 @@ import NotesService from '/imports/ui/components/notes/service'; import lockContextContainer from '/imports/ui/components/lock-viewers/context/container'; import UserNotes from './component'; import { layoutSelectInput, layoutDispatch } from '../../../layout/context'; +import UserNotesContainerGraphql from '../../user-list-graphql/user-list-content/user-notes/component'; const UserNotesContainer = (props) => { const sidebarContent = layoutSelectInput((i) => i.sidebarContent); @@ -12,7 +13,7 @@ const UserNotesContainer = (props) => { return ; }; -export default lockContextContainer(withTracker(({ userLocks }) => { +lockContextContainer(withTracker(({ userLocks }) => { const shouldDisableNotes = userLocks.userNotes; return { unread: NotesService.hasUnreadNotes(), @@ -20,3 +21,5 @@ export default lockContextContainer(withTracker(({ userLocks }) => { isPinned: NotesService.isSharedNotesPinned(), }; })(UserNotesContainer)); + +export default UserNotesContainerGraphql; diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-graphql/user-list-content/user-notes/component.tsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-graphql/user-list-content/user-notes/component.tsx new file mode 100644 index 0000000000..edbde32288 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-graphql/user-list-content/user-notes/component.tsx @@ -0,0 +1,226 @@ +import React, { useEffect, useState } from 'react'; +import { Meteor } from 'meteor/meteor'; +import { useSubscription } from '@apollo/client'; +import { defineMessages, useIntl } from 'react-intl'; +import Icon from '/imports/ui/components/common/icon/component'; +import NotesService from '/imports/ui/components/notes/notes-graphql/service'; +import lockContextContainer from '/imports/ui/components/lock-viewers/context/container'; +import { PANELS } from '/imports/ui/components/layout/enums'; +import { notify } from '/imports/ui/services/notification'; +import { layoutSelectInput, layoutDispatch } from '/imports/ui/components/layout/context'; +import { + PINNED_PAD_SUBSCRIPTION, + PinnedPadSubscriptionResponse, +} from '/imports/ui/components/notes/notes-graphql/queries'; +import Styled from './styles'; +import { usePreviousValue } from '/imports/ui/components/utils/hooks'; +import useRev from '/imports/ui/components/pads/pads-graphql/hooks/useRev'; +import useNotesLastRev from '/imports/ui/components/notes/notes-graphql/hooks/useNotesLastRev'; +import useHasUnreadNotes from '/imports/ui/components/notes/notes-graphql/hooks/useHasUnreadNotes'; + +const NOTES_CONFIG = Meteor.settings.public.notes; + +const intlMessages = defineMessages({ + title: { + id: 'app.userList.notesTitle', + description: 'Title for the notes list', + }, + pinnedNotification: { + id: 'app.notes.pinnedNotification', + description: 'Notification text for pinned shared notes', + }, + sharedNotes: { + id: 'app.notes.title', + description: 'Title for the shared notes', + }, + sharedNotesPinned: { + id: 'app.notes.titlePinned', + description: 'Title for the shared notes pinned', + }, + unreadContent: { + id: 'app.userList.notesListItem.unreadContent', + description: 'Aria label for notes unread content', + }, + locked: { + id: 'app.notes.locked', + description: '', + }, + byModerator: { + id: 'app.userList.byModerator', + description: '', + }, + disabled: { + id: 'app.notes.disabled', + description: 'Aria description for disabled notes button', + }, +}); + +interface UserNotesGraphqlProps { + isPinned: boolean, + disableNotes: boolean, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sidebarContentPanel: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + layoutContextDispatch: any, + hasUnreadNotes: boolean, + markNotesAsRead: () => void, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + toggleNotesPanel: (sidebarContentPanel: any, layoutContextDispatch: any) => void, + isEnabled: () => boolean, +} + +interface UserNotesContainerGraphqlProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + userLocks: any; +} + +const UserNotesGraphql: React.FC = (props) => { + const { + isPinned, + disableNotes, + sidebarContentPanel, + layoutContextDispatch, + isEnabled, + hasUnreadNotes, + markNotesAsRead, + toggleNotesPanel, + } = props; + const [unread, setUnread] = useState(false); + const [pinWasNotified, setPinWasNotified] = useState(false); + const intl = useIntl(); + const prevSidebarContentPanel = usePreviousValue(sidebarContentPanel); + const prevIsPinned = usePreviousValue(isPinned); + + useEffect(() => { + setUnread(hasUnreadNotes); + }, []); + + if (isPinned && !pinWasNotified) { + notify(intl.formatMessage(intlMessages.pinnedNotification), 'info', 'copy', { pauseOnFocusLoss: false }); + setPinWasNotified(true); + } + + const notesOpen = sidebarContentPanel === PANELS.SHARED_NOTES && !isPinned; + const notesClosed = (prevSidebarContentPanel === PANELS.SHARED_NOTES + && sidebarContentPanel !== PANELS.SHARED_NOTES) + || (prevIsPinned && !isPinned); + + if ((notesOpen || notesClosed) && unread) { + markNotesAsRead(); + } + if (!unread && hasUnreadNotes) { + setUnread(true); + } + if (unread && !hasUnreadNotes) { + setUnread(false); + } + if (prevIsPinned && !isPinned && pinWasNotified) { + setPinWasNotified(false); + } + + const renderNotes = () => { + let notification = null; + if (unread && !isPinned) { + notification = ( + + + + ); + } + + const showTitle = isPinned ? intl.formatMessage(intlMessages.sharedNotesPinned) + : intl.formatMessage(intlMessages.sharedNotes); + return ( + // @ts-ignore + toggleNotesPanel(sidebarContentPanel, layoutContextDispatch)} + // @ts-ignore + onKeyDown={(e) => { + if (e.key === 'Enter') { + toggleNotesPanel(sidebarContentPanel, layoutContextDispatch); + } + }} + as={isPinned ? 'button' : 'div'} + disabled={isPinned} + $disabled={isPinned} + > + {/* @ts-ignore */} + +
+ + { showTitle } + + {disableNotes + ? ( + + {/* @ts-ignore */} + + {`${intl.formatMessage(intlMessages.locked)} ${intl.formatMessage(intlMessages.byModerator)}`} + + ) : null} + {isPinned + ? ( + {`${intl.formatMessage(intlMessages.disabled)}`} + ) : null} +
+ {notification} +
+ ); + }; + + if (!isEnabled()) return null; + + return ( + + + + {intl.formatMessage(intlMessages.title)} + + + + + {renderNotes()} + + + + ); +}; + +const UserNotesContainerGraphql: React.FC = (props) => { + const { userLocks } = props; + const disableNotes = userLocks.userNotes; + const { data: pinnedPadData } = useSubscription(PINNED_PAD_SUBSCRIPTION); + const isPinned = !!pinnedPadData && pinnedPadData.sharedNotes[0]?.sharedNotesExtId === NOTES_CONFIG.id; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sidebarContent = layoutSelectInput((i: any) => i.sidebarContent); + const { sidebarContentPanel } = sidebarContent; + const layoutContextDispatch = layoutDispatch(); + + const rev = useRev(NotesService.ID); + const { setNotesLastRev } = useNotesLastRev(); + + const hasUnreadNotes = useHasUnreadNotes(); + const markNotesAsRead = () => setNotesLastRev(rev); + + return ( + + ); +}; + +export default lockContextContainer(UserNotesContainerGraphql); diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-graphql/user-list-content/user-notes/styles.ts b/bigbluebutton-html5/imports/ui/components/user-list/user-list-graphql/user-list-content/user-notes/styles.ts new file mode 100644 index 0000000000..7ac236aeea --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-graphql/user-list-content/user-notes/styles.ts @@ -0,0 +1,56 @@ +import styled from 'styled-components'; + +import Styled from '/imports/ui/components/user-list/styles'; +import StyledContent from '/imports/ui/components/user-list/user-list-content/styles'; +import { colorGray } from '/imports/ui/stylesheets/styled-components/palette'; +import { + fontSizeSmall, + fontSizeSmaller, + fontSizeXS, +} from '/imports/ui/stylesheets/styled-components/typography'; + +const UnreadMessages = styled(StyledContent.UnreadMessages)``; + +const UnreadMessagesText = styled(StyledContent.UnreadMessagesText)``; + +const ListItem = styled(StyledContent.ListItem)` + i{ left: 4px; } +`; + +const NotesTitle = styled.div` + font-weight: 400; + font-size: ${fontSizeSmall}; +`; + +const NotesLock = styled.div` + font-weight: 200; + font-size: ${fontSizeSmaller}; + color: ${colorGray}; + + > i { + font-size: ${fontSizeXS}; + } +`; + +const Messages = styled(Styled.Messages)``; + +const Container = styled(StyledContent.Container)``; + +const SmallTitle = styled(Styled.SmallTitle)``; + +const ScrollableList = styled(StyledContent.ScrollableList)``; + +const List = styled(StyledContent.List)``; + +export default { + UnreadMessages, + UnreadMessagesText, + ListItem, + NotesTitle, + NotesLock, + Messages, + Container, + SmallTitle, + ScrollableList, + List, +}; diff --git a/bigbluebutton-html5/imports/ui/components/utils/hooks/index.js b/bigbluebutton-html5/imports/ui/components/utils/hooks/index.ts similarity index 70% rename from bigbluebutton-html5/imports/ui/components/utils/hooks/index.js rename to bigbluebutton-html5/imports/ui/components/utils/hooks/index.ts index 4e3b0b296b..c16f4e5a8e 100644 --- a/bigbluebutton-html5/imports/ui/components/utils/hooks/index.js +++ b/bigbluebutton-html5/imports/ui/components/utils/hooks/index.ts @@ -3,11 +3,11 @@ import { useEffect, useRef } from 'react'; /** * Custom hook to get previous value. It can be used, * for example, to get previous props or state. - * @param {*} value + * @param {*} value Value to be tracked * @returns The previous value. */ -export const usePreviousValue = (value) => { - const ref = useRef(); +export const usePreviousValue = (value: T) => { + const ref = useRef(); useEffect(() => { ref.current = value; }); From 68394d4b14375f8f65af050289a25771ecfad162 Mon Sep 17 00:00:00 2001 From: KDSBrowne Date: Tue, 23 Jan 2024 10:36:26 -0500 Subject: [PATCH 0220/1039] fix: Improve Arrow Shape Handling With Tldraw v2 (#19376) * move cleaning arrow shape to akka --- .../core/apps/WhiteboardModel.scala | 65 +++++++++++--- .../ui/components/whiteboard/component.jsx | 85 +++++++------------ 2 files changed, 85 insertions(+), 65 deletions(-) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/WhiteboardModel.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/WhiteboardModel.scala index 7908a30d00..00e1d9ea18 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/WhiteboardModel.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/WhiteboardModel.scala @@ -47,19 +47,31 @@ class WhiteboardModel extends SystemConfiguration { }).toMap def addAnnotations(wbId: String, userId: String, annotations: Array[AnnotationVO], isPresenter: Boolean, isModerator: Boolean): Array[AnnotationVO] = { - var annotationsAdded = Array[AnnotationVO]() val wb = getWhiteboard(wbId) + + var annotationsAdded = Array[AnnotationVO]() var newAnnotationsMap = wb.annotationsMap + for (annotation <- annotations) { val oldAnnotation = wb.annotationsMap.get(annotation.id) if (!oldAnnotation.isEmpty) { val hasPermission = isPresenter || isModerator || oldAnnotation.get.userId == userId if (hasPermission) { - val newAnnotation = oldAnnotation.get.copy(annotationInfo = deepMerge(oldAnnotation.get.annotationInfo, annotation.annotationInfo)) + // Merge old and new annotation properties + val mergedAnnotationInfo = deepMerge(oldAnnotation.get.annotationInfo, annotation.annotationInfo) + + // Apply cleaning if it's an arrow annotation + val finalAnnotationInfo = if (annotation.annotationInfo.get("type").contains("arrow")) { + cleanArrowAnnotationProps(mergedAnnotationInfo) + } else { + mergedAnnotationInfo + } + + val newAnnotation = oldAnnotation.get.copy(annotationInfo = finalAnnotationInfo) newAnnotationsMap += (annotation.id -> newAnnotation) - annotationsAdded :+= annotation - PresAnnotationDAO.insertOrUpdate(newAnnotation, annotation) - println(s"Updated annotation onpage [${wb.id}]. After numAnnotations=[${newAnnotationsMap.size}].") + annotationsAdded :+= newAnnotation + PresAnnotationDAO.insertOrUpdate(newAnnotation, newAnnotation) + println(s"Updated annotation on page [${wb.id}]. After numAnnotations=[${newAnnotationsMap.size}].") } else { println(s"User $userId doesn't have permission to edit annotation ${annotation.id}, ignoring...") } @@ -69,40 +81,67 @@ class WhiteboardModel extends SystemConfiguration { PresAnnotationDAO.insertOrUpdate(annotation, annotation) println(s"Adding annotation to page [${wb.id}]. After numAnnotations=[${newAnnotationsMap.size}].") } else { - println(s"New annotation [${annotation.id}] with no type, ignoring (probably received a remove message before and now the shape is incomplete, ignoring...") + println(s"New annotation [${annotation.id}] with no type, ignoring...") } } + val newWb = wb.copy(annotationsMap = newAnnotationsMap) saveWhiteboard(newWb) annotationsAdded } + private def cleanArrowAnnotationProps(annotationInfo: Map[String, _]): Map[String, _] = { + annotationInfo.get("props") match { + case Some(props: Map[String, _]) => + val cleanedProps = props.map { + case ("end", endProps: Map[String, _]) => "end" -> cleanEndOrStartProps(endProps) + case ("start", startProps: Map[String, _]) => "start" -> cleanEndOrStartProps(startProps) + case other => other + } + annotationInfo + ("props" -> cleanedProps) + case _ => annotationInfo + } + } + + private def cleanEndOrStartProps(props: Map[String, _]): Map[String, _] = { + props.get("type") match { + case Some("binding") => props - ("x", "y") // Remove 'x' and 'y' for 'binding' type + case Some("point") => props - ("boundShapeId", "normalizedAnchor", "isExact") // Remove unwanted properties for 'point' type + case _ => props + } + } + def getHistory(wbId: String): Array[AnnotationVO] = { val wb = getWhiteboard(wbId) wb.annotationsMap.values.toArray } def deleteAnnotations(wbId: String, userId: String, annotationsIds: Array[String], isPresenter: Boolean, isModerator: Boolean): Array[String] = { - var annotationsIdsRemoved = Array[String]() val wb = getWhiteboard(wbId) + + var annotationsIdsRemoved = Array[String]() var newAnnotationsMap = wb.annotationsMap for (annotationId <- annotationsIds) { val annotation = wb.annotationsMap.get(annotationId) - if (!annotation.isEmpty) { + if (annotation.isDefined) { val hasPermission = isPresenter || isModerator || annotation.get.userId == userId if (hasPermission) { newAnnotationsMap -= annotationId - println("Removing annotation on page [" + wb.id + "]. After numAnnotations=[" + newAnnotationsMap.size + "].") + println(s"Removed annotation $annotationId on page [${wb.id}]. After numAnnotations=[${newAnnotationsMap.size}].") annotationsIdsRemoved :+= annotationId } else { - println("User doesn't have permission to remove this annotation, ignoring...") + println(s"User $userId doesn't have permission to remove annotation $annotationId, ignoring...") } + } else { + println(s"Annotation $annotationId not found while trying to delete it.") } } - val newWb = wb.copy(annotationsMap = newAnnotationsMap) - saveWhiteboard(newWb) + + // Update whiteboard and save + val updatedWb = wb.copy(annotationsMap = newAnnotationsMap) + saveWhiteboard(updatedWb) annotationsIdsRemoved.map(PresAnnotationDAO.delete(wbId, userId, _)) @@ -130,4 +169,4 @@ class WhiteboardModel extends SystemConfiguration { } def getChangedModeOn(wbId: String): Long = getWhiteboard(wbId).changedModeOn -} +} \ No newline at end of file diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx index ecc7fa1025..0e5db3ec57 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx @@ -80,21 +80,6 @@ const determineViewerFitToWidth = (currentPresentationPage) => { ); }; -const cleanArrowShapeProps = (shapeProp) => { - if (!shapeProp) return; - - if (shapeProp.type === "binding") { - delete shapeProp.x; - delete shapeProp.y; - } - - if (shapeProp.type === "point") { - delete shapeProp.boundShapeId; - delete shapeProp.normalizedAnchor; - delete shapeProp.isExact; - } -}; - export default Whiteboard = React.memo(function Whiteboard(props) { const { isPresenter, @@ -117,11 +102,11 @@ export default Whiteboard = React.memo(function Whiteboard(props) { svgUri, maxStickyNoteLength, fontFamily, - colorStyle, - dashStyle, - fillStyle, - fontStyle, - sizeStyle, + colorStyle, + dashStyle, + fillStyle, + fontStyle, + sizeStyle, hasShapeAccess, presentationAreaHeight, presentationAreaWidth, @@ -268,10 +253,6 @@ export default Whiteboard = React.memo(function Whiteboard(props) { }); } - if (diff?.type === "arrow") { - cleanArrowShapeProps(diff?.props?.end); - cleanArrowShapeProps(diff?.props?.start); - } toUpdate.push(diff); } } @@ -700,29 +681,29 @@ export default Whiteboard = React.memo(function Whiteboard(props) { console.log("EDITOR : ", editor); const debouncePersistShape = debounce({ delay: 0 }, persistShape); - - const colorStyles = ['black', 'blue', 'green', 'grey', 'light-blue', 'light-green', 'light-red', 'light-violet', 'orange', 'red', 'violet', 'yellow']; - const dashStyles = ['dashed', 'dotted', 'draw', 'solid']; - const fillStyles = ['none', 'pattern', 'semi', 'solid']; - const fontStyles = ['draw','mono','sans', 'serif']; - const sizeStyles = ['l', 'm', 's', 'xl']; - - if ( colorStyles.includes(colorStyle) ) { - editor.setStyleForNextShapes(DefaultColorStyle, colorStyle); - } - if ( dashStyles.includes(dashStyle) ) { - editor.setStyleForNextShapes(DefaultDashStyle, dashStyle); - } - if ( fillStyles.includes(fillStyle) ) { - editor.setStyleForNextShapes(DefaultFillStyle, fillStyle); - } - if ( fontStyles.includes(fontStyle)) { - editor.setStyleForNextShapes(DefaultFontStyle, fontStyle); - } - if ( sizeStyles.includes(sizeStyle) ) { - editor.setStyleForNextShapes(DefaultSizeStyle, sizeStyle); - } - + + const colorStyles = ['black', 'blue', 'green', 'grey', 'light-blue', 'light-green', 'light-red', 'light-violet', 'orange', 'red', 'violet', 'yellow']; + const dashStyles = ['dashed', 'dotted', 'draw', 'solid']; + const fillStyles = ['none', 'pattern', 'semi', 'solid']; + const fontStyles = ['draw','mono','sans', 'serif']; + const sizeStyles = ['l', 'm', 's', 'xl']; + + if ( colorStyles.includes(colorStyle) ) { + editor.setStyleForNextShapes(DefaultColorStyle, colorStyle); + } + if ( dashStyles.includes(dashStyle) ) { + editor.setStyleForNextShapes(DefaultDashStyle, dashStyle); + } + if ( fillStyles.includes(fillStyle) ) { + editor.setStyleForNextShapes(DefaultFillStyle, fillStyle); + } + if ( fontStyles.includes(fontStyle)) { + editor.setStyleForNextShapes(DefaultFontStyle, fontStyle); + } + if ( sizeStyles.includes(sizeStyle) ) { + editor.setStyleForNextShapes(DefaultSizeStyle, sizeStyle); + } + editor.store.listen( (entry) => { const { changes } = entry; @@ -944,11 +925,11 @@ Whiteboard.propTypes = { svgUri: PropTypes.string, maxStickyNoteLength: PropTypes.number.isRequired, fontFamily: PropTypes.string.isRequired, - colorStyle: PropTypes.string.isRequired, - dashStyle: PropTypes.string.isRequired, - fillStyle: PropTypes.string.isRequired, - fontStyle: PropTypes.string.isRequired, - sizeStyle: PropTypes.string.isRequired, + colorStyle: PropTypes.string.isRequired, + dashStyle: PropTypes.string.isRequired, + fillStyle: PropTypes.string.isRequired, + fontStyle: PropTypes.string.isRequired, + sizeStyle: PropTypes.string.isRequired, hasShapeAccess: PropTypes.func.isRequired, presentationAreaHeight: PropTypes.number.isRequired, presentationAreaWidth: PropTypes.number.isRequired, From d3960e1d72366f05986127eecad6684576608f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Tue, 23 Jan 2024 13:02:06 -0300 Subject: [PATCH 0221/1039] Remove unused methods --- .../imports/api/pads/server/methods.js | 6 ---- .../api/pads/server/methods/createSession.js | 27 ---------------- .../api/pads/server/methods/getPadId.js | 32 ------------------- .../imports/api/pads/server/methods/pinPad.js | 29 ----------------- 4 files changed, 94 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/pads/server/methods/createSession.js delete mode 100644 bigbluebutton-html5/imports/api/pads/server/methods/getPadId.js delete mode 100644 bigbluebutton-html5/imports/api/pads/server/methods/pinPad.js diff --git a/bigbluebutton-html5/imports/api/pads/server/methods.js b/bigbluebutton-html5/imports/api/pads/server/methods.js index 535aba026e..ff9e7ed21a 100644 --- a/bigbluebutton-html5/imports/api/pads/server/methods.js +++ b/bigbluebutton-html5/imports/api/pads/server/methods.js @@ -1,12 +1,6 @@ import { Meteor } from 'meteor/meteor'; import createGroup from './methods/createGroup'; -import createSession from './methods/createSession'; -import getPadId from './methods/getPadId'; -import pinPad from './methods/pinPad'; Meteor.methods({ createGroup, - createSession, - getPadId, - pinPad, }); diff --git a/bigbluebutton-html5/imports/api/pads/server/methods/createSession.js b/bigbluebutton-html5/imports/api/pads/server/methods/createSession.js deleted file mode 100644 index e2ce8fd7ce..0000000000 --- a/bigbluebutton-html5/imports/api/pads/server/methods/createSession.js +++ /dev/null @@ -1,27 +0,0 @@ -import RedisPubSub from '/imports/startup/server/redis'; -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import Logger from '/imports/startup/server/logger'; - -export default function createSession(externalId) { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'PadCreateSessionReqMsg'; - - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(externalId, String); - - const payload = { - externalId, - }; - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method createSession ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/api/pads/server/methods/getPadId.js b/bigbluebutton-html5/imports/api/pads/server/methods/getPadId.js deleted file mode 100644 index 2b69d61695..0000000000 --- a/bigbluebutton-html5/imports/api/pads/server/methods/getPadId.js +++ /dev/null @@ -1,32 +0,0 @@ -import { check } from 'meteor/check'; -import Pads from '/imports/api/pads'; -import { extractCredentials } from '/imports/api/common/server/helpers'; - -export default async function getPadId(externalId) { - try { - const { meetingId } = extractCredentials(this.userId); - - check(meetingId, String); - check(externalId, String); - - const pad = await Pads.findOneAsync( - { - meetingId, - externalId, - }, - { - fields: { - padId: 1, - }, - }, - ); - - if (pad && pad.padId) { - return pad.padId; - } - - return null; - } catch (err) { - return null; - } -} diff --git a/bigbluebutton-html5/imports/api/pads/server/methods/pinPad.js b/bigbluebutton-html5/imports/api/pads/server/methods/pinPad.js deleted file mode 100644 index 630cd281c2..0000000000 --- a/bigbluebutton-html5/imports/api/pads/server/methods/pinPad.js +++ /dev/null @@ -1,29 +0,0 @@ -import RedisPubSub from '/imports/startup/server/redis'; -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import Logger from '/imports/startup/server/logger'; - -export default function pinPad(externalId, pinned) { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'PadPinnedReqMsg'; - - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(externalId, String); - check(pinned, Boolean); - - const payload = { - externalId, - pinned, - }; - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method pinPad ${err.stack}`); - } -} From f5e65962e5b42e4e47d1f01fb27708c82fd5960b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Tue, 23 Jan 2024 13:14:29 -0300 Subject: [PATCH 0222/1039] Tweak element types --- .../ui/components/polling/polling-graphql/component.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx index a26e9bc313..d185a7b0db 100644 --- a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx @@ -1,5 +1,5 @@ import React, { - ElementRef, useEffect, useMemo, useRef, useState, + useEffect, useMemo, useRef, useState, } from 'react'; import { useMutation, useSubscription } from '@apollo/client'; import { defineMessages, useIntl } from 'react-intl'; @@ -94,8 +94,8 @@ const PollingGraphql: React.FC = (props) => { const [typedAns, setTypedAns] = useState(''); const [checkedAnswers, setCheckedAnswers] = useState>([]); const intl = useIntl(); - const responseInput = useRef>(null); - const pollingContainer = useRef>(null); + const responseInput = useRef(null); + const pollingContainer = useRef(null); useEffect(() => { play(); From ea463b37b54f9919b205277819547e83695ffaa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Tue, 23 Jan 2024 13:41:15 -0300 Subject: [PATCH 0223/1039] Move all the necessary services to own service --- .../polling/polling-graphql/component.tsx | 30 +++++++------------ .../polling/polling-graphql/service.ts | 19 +++++++++++- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx index d185a7b0db..a65a79d721 100644 --- a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx @@ -4,11 +4,8 @@ import React, { import { useMutation, useSubscription } from '@apollo/client'; import { defineMessages, useIntl } from 'react-intl'; import { Meteor } from 'meteor/meteor'; -import AudioService from '/imports/ui/components/audio/service'; import Checkbox from '/imports/ui/components/common/checkbox/component'; -import PollService from '/imports/ui/components/poll/service'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; -import { isPollingEnabled } from '/imports/ui/services/features'; import { POLL_SUBMIT_TYPED_VOTE, POLL_SUBMIT_VOTE, @@ -17,7 +14,7 @@ import { hasPendingPoll, HasPendingPollResponse, } from './queries'; -import { shouldStackOptions } from './service'; +import Service from './service'; import Styled from './styles'; const MAX_INPUT_CHARS = Meteor.settings.public.poll.maxTypedAnswerLength; @@ -66,6 +63,7 @@ interface PollingGraphqlProps { pollAnswerIds: Record; pollTypes: Record; isDefaultPoll: (pollType: string) => boolean; + playAlert: () => void; poll: { pollId: string; multipleResponses: boolean; @@ -89,6 +87,7 @@ const PollingGraphql: React.FC = (props) => { pollAnswerIds, pollTypes, isDefaultPoll, + playAlert, } = props; const [typedAns, setTypedAns] = useState(''); @@ -98,22 +97,12 @@ const PollingGraphql: React.FC = (props) => { const pollingContainer = useRef(null); useEffect(() => { - play(); + playAlert(); if (pollingContainer.current) { pollingContainer.current.focus(); } }, []); - const play = () => { - AudioService.playAlertSound( - `${ - Meteor.settings.public.app.cdn - + Meteor.settings.public.app.basename - + Meteor.settings.public.app.instanceId - }/resources/sounds/Poll.mp3`, - ); - }; - const handleUpdateResponseInput = (e: React.ChangeEvent) => { if (responseInput.current) { responseInput.current.value = validateInput(e.target.value); @@ -357,9 +346,9 @@ const PollingGraphqlContainer: React.FC = () => { const pollData = meetingData && meetingData.polls[0]; const userData = pollData && pollData.users[0]; const pollExists = !!userData; - const showPolling = pollExists && !currentUserData?.presenter && isPollingEnabled(); + const showPolling = pollExists && !currentUserData?.presenter && Service.isPollingEnabled(); const stackOptions = useMemo( - () => !!pollData && shouldStackOptions(pollData.options.map((o) => o.optionDesc)), + () => !!pollData && Service.shouldStackOptions(pollData.options.map((o) => o.optionDesc)), [pollData], ); @@ -391,9 +380,10 @@ const PollingGraphqlContainer: React.FC = () => { ...pollData, stackOptions, }} - pollAnswerIds={PollService.pollAnswerIds} - pollTypes={PollService.pollTypes} - isDefaultPoll={PollService.isDefaultPoll} + pollAnswerIds={Service.pollAnswerIds} + isDefaultPoll={Service.isDefaultPoll} + pollTypes={Service.pollTypes} + playAlert={Service.playAlert} /> ); }; diff --git a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/service.ts b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/service.ts index 848179b395..8f2a1dfec4 100644 --- a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/service.ts +++ b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/service.ts @@ -1,5 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import PollService from '/imports/ui/components/poll/service'; +import AudioService from '/imports/ui/components/audio/service'; +import { isPollingEnabled } from '/imports/ui/services/features'; + const MAX_CHAR_LENGTH = 5; +const APP_CONFIG = Meteor.settings.public.app; export const shouldStackOptions = (keys: Array) => keys.some((k) => k.length > MAX_CHAR_LENGTH); -export default { shouldStackOptions }; +const playAlert = () => AudioService.playAlertSound( + `${APP_CONFIG.cdn + APP_CONFIG.basename + APP_CONFIG.instanceId}/resources/sounds/Poll.mp3`, +); + +export default { + shouldStackOptions, + pollAnswerIds: PollService.pollAnswerIds, + pollTypes: PollService.pollTypes, + isDefaultPoll: PollService.isDefaultPoll, + playAlert, + isPollingEnabled, +}; From eb135d86ef01007007aaf4120e30e098914317a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Tue, 23 Jan 2024 13:42:47 -0300 Subject: [PATCH 0224/1039] Remove unused interface --- .../ui/components/polling/polling-graphql/component.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx index a65a79d721..db63329fcb 100644 --- a/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/polling/polling-graphql/component.tsx @@ -55,8 +55,6 @@ const validateInput = (i: string) => { return input; }; -interface PollingGraphqlContainerProps {} - interface PollingGraphqlProps { handleTypedVote: (pollId: string, answer: string) => void; handleVote: (pollId: string, answerIds: Array) => void; @@ -328,7 +326,7 @@ const PollingGraphql: React.FC = (props) => { ); }; -const PollingGraphqlContainer: React.FC = () => { +const PollingGraphqlContainer: React.FC = () => { const { data: currentUserData } = useCurrentUser((u) => ({ userId: u.userId, presenter: u.presenter, From 6d12508f41728118a1206c6b0e36d11ef3a172fb Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Tue, 23 Jan 2024 15:28:32 -0300 Subject: [PATCH 0225/1039] feature (graphql-middlware): Enhancing GraphQL Stream Subscriptions: Efficient Handling of Connection Resets and Cursor Management (#19481) --- .../internal/common/StreamCursorUtils.go | 135 ++++++++++++++++++ .../internal/common/types.go | 8 +- .../internal/hascli/conn/reader/reader.go | 21 ++- .../internal/hascli/conn/writer/writer.go | 28 +++- .../hascli/retransmiter/retransmiter.go | 8 +- 5 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 bbb-graphql-middleware/internal/common/StreamCursorUtils.go diff --git a/bbb-graphql-middleware/internal/common/StreamCursorUtils.go b/bbb-graphql-middleware/internal/common/StreamCursorUtils.go new file mode 100644 index 0000000000..872a13dc32 --- /dev/null +++ b/bbb-graphql-middleware/internal/common/StreamCursorUtils.go @@ -0,0 +1,135 @@ +package common + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +func GetStreamCursorPropsFromQuery(payload map[string]interface{}, query string) (string, string, interface{}) { + streamCursorField := "" + streamCursorVariableName := "" + var streamCursorInitialValue interface{} + + cursorInitialValueRePattern := regexp.MustCompile(`cursor:\s*\{\s*initial_value\s*:\s*\{\s*([^:]+):\s*([^}]+)\s*}\s*}`) + matches := cursorInitialValueRePattern.FindStringSubmatch(query) + if matches != nil { + streamCursorField = matches[1] + if strings.HasPrefix(matches[2], "$") { + streamCursorVariableName, _ = strings.CutPrefix(matches[2], "$") + if variables, okVariables := payload["variables"].(map[string]interface{}); okVariables { + if targetVariableValue, okTargetVariableValue := variables[streamCursorVariableName]; okTargetVariableValue { + streamCursorInitialValue = targetVariableValue + } + } + } else { + streamCursorInitialValue = matches[2] + } + } + + return streamCursorField, streamCursorVariableName, streamCursorInitialValue +} + +func GetLastStreamCursorValueFromReceivedMessage(messageAsMap map[string]interface{}, streamCursorField string) interface{} { + var lastStreamCursorValue interface{} + + if payload, okPayload := messageAsMap["payload"].(map[string]interface{}); okPayload { + if data, okData := payload["data"].(map[string]interface{}); okData { + //Data will have only one prop, `range` because its name is unknown + for _, dataItem := range data { + currentDataProp, okCurrentDataProp := dataItem.([]interface{}) + if okCurrentDataProp && len(currentDataProp) > 0 { + // Get the last item directly (once it will contain the last cursor value) + lastItemOfMessage := currentDataProp[len(currentDataProp)-1] + if lastItemOfMessageAsMap, currDataOk := lastItemOfMessage.(map[string]interface{}); currDataOk { + if lastItemValue, okLastItemValue := lastItemOfMessageAsMap[streamCursorField]; okLastItemValue { + lastStreamCursorValue = lastItemValue + } + } + } + } + } + } + + return lastStreamCursorValue +} + +func PatchQueryIncludingCursorField(originalQuery string, cursorField string) string { + if cursorField == "" { + return originalQuery + } + + lastIndex := strings.LastIndex(originalQuery, "{") + if lastIndex == -1 { + return originalQuery + } + + // It will include the cursorField at the beginning of the list of fields + // It's not a problem if the field be duplicated in the list, Hasura just ignore the second occurrence + return originalQuery[:lastIndex+1] + "\n " + cursorField + originalQuery[lastIndex+1:] +} + +func PatchQuerySettingLastCursorValue(subscription GraphQlSubscription) interface{} { + message := subscription.Message + payload, okPayload := message["payload"].(map[string]interface{}) + + if okPayload { + if subscription.StreamCursorVariableName != "" { + /**** This stream has its cursor value set through variables ****/ + if variables, okVariables := payload["variables"].(map[string]interface{}); okVariables { + if variables[subscription.StreamCursorVariableName] != subscription.StreamCursorCurrValue { + variables[subscription.StreamCursorVariableName] = subscription.StreamCursorCurrValue + payload["variables"] = variables + message["payload"] = payload + } + } + } else { + /**** This stream has its cursor value set through inline value (not variables) ****/ + query, okQuery := payload["query"].(string) + if okQuery { + cursorInitialValueRePattern := regexp.MustCompile(`cursor:\s*\{\s*initial_value\s*:\s*\{\s*([^:]+:\s*[^}]+)\s*}\s*}`) + newValue := "" + + replaceInitialValueFunc := func(match string) string { + switch v := subscription.StreamCursorCurrValue.(type) { + case string: + newValue = v + + //Append quotes if it is missing, it will be necessary when appending to the query + if !strings.HasPrefix(v, "\"") { + newValue = "\"" + newValue + } + if !strings.HasSuffix(v, "\"") { + newValue = newValue + "\"" + } + case int: + newValue = strconv.Itoa(v) + case float32: + myFloat64 := float64(v) + newValue = strconv.FormatFloat(myFloat64, 'f', -1, 32) + case float64: + newValue = strconv.FormatFloat(v, 'f', -1, 64) + default: + newValue = "" + } + + if newValue != "" { + replacement := subscription.StreamCursorField + ": " + newValue + return fmt.Sprintf("cursor: {initial_value: {%s}}", replacement) + } else { + return match + } + } + + newQuery := cursorInitialValueRePattern.ReplaceAllStringFunc(query, replaceInitialValueFunc) + if query != newQuery { + payload["query"] = newQuery + message["payload"] = payload + } + } + } + } + + return message +} diff --git a/bbb-graphql-middleware/internal/common/types.go b/bbb-graphql-middleware/internal/common/types.go index 666ccc8932..15bc7e5db5 100644 --- a/bbb-graphql-middleware/internal/common/types.go +++ b/bbb-graphql-middleware/internal/common/types.go @@ -19,8 +19,12 @@ const ( type GraphQlSubscription struct { Id string - Message interface{} + Message map[string]interface{} Type QueryType + OperationName string + StreamCursorField string + StreamCursorVariableName string + StreamCursorCurrValue interface{} JsonPatchSupported bool // indicate if client support Json Patch for this subscription LastSeenOnHasuraConnetion string // id of the hasura connection that this query was active } @@ -31,7 +35,7 @@ type BrowserConnection struct { Context context.Context // browser connection context ActiveSubscriptions map[string]GraphQlSubscription // active subscriptions of this connection (start, but no stop) ActiveSubscriptionsMutex sync.RWMutex // mutex to control the map usage - ConnectionInitMessage interface{} // init message received in this connection (to be used on hasura reconnect) + ConnectionInitMessage map[string]interface{} // init message received in this connection (to be used on hasura reconnect) HasuraConnection *HasuraConnection // associated hasura connection Disconnected bool // indicate if the connection is gone } diff --git a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go index 486f76b641..c66a299c35 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go +++ b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go @@ -1,6 +1,8 @@ package reader import ( + "context" + "errors" "github.com/iMDT/bbb-graphql-middleware/internal/common" "github.com/iMDT/bbb-graphql-middleware/internal/hascli/retransmiter" "github.com/iMDT/bbb-graphql-middleware/internal/msgpatch" @@ -23,7 +25,11 @@ func HasuraConnectionReader(hc *common.HasuraConnection, fromHasuraToBrowserChan var message interface{} err := wsjson.Read(hc.Context, hc.Websocket, &message) if err != nil { - log.Errorf("Error: %v", err) + if errors.Is(err, context.Canceled) { + log.Debugf("Closing ws connection as Context was cancelled!") + } else { + log.Errorf("Error reading message from Hasura: %v", err) + } return } @@ -59,6 +65,19 @@ func HasuraConnectionReader(hc *common.HasuraConnection, fromHasuraToBrowserChan subscription.Type == common.Subscription { msgpatch.PatchMessage(&messageAsMap, hc.Browserconn) } + + //Set last cursor value for stream + if subscription.Type == common.Streaming { + lastCursor := common.GetLastStreamCursorValueFromReceivedMessage(messageAsMap, subscription.StreamCursorField) + if lastCursor != nil && subscription.StreamCursorCurrValue != lastCursor { + subscription.StreamCursorCurrValue = lastCursor + + hc.Browserconn.ActiveSubscriptionsMutex.Lock() + hc.Browserconn.ActiveSubscriptions[queryId] = subscription + hc.Browserconn.ActiveSubscriptionsMutex.Unlock() + } + + } } // Write the message to browser diff --git a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go index 8b34d03609..334bc3c01a 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go +++ b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go @@ -41,7 +41,11 @@ RangeLoop: //Identify type based on query string messageType := common.Query + streamCursorField := "" + streamCursorVariableName := "" + var streamCursorInitialValue interface{} payload := fromBrowserMessageAsMap["payload"].(map[string]interface{}) + query, ok := payload["query"].(string) if ok { if strings.HasPrefix(query, "subscription") { @@ -49,6 +53,18 @@ RangeLoop: if strings.Contains(query, "_stream(") && strings.Contains(query, "cursor: {") { messageType = common.Streaming + + browserConnection.ActiveSubscriptionsMutex.RLock() + _, queryIdExists := browserConnection.ActiveSubscriptions[queryId] + browserConnection.ActiveSubscriptionsMutex.RUnlock() + if !queryIdExists { + streamCursorField, streamCursorVariableName, streamCursorInitialValue = common.GetStreamCursorPropsFromQuery(payload, query) + + //It's necessary to assure the cursor field will return in the result of the query + //To be able to store the last received cursor value + payload["query"] = common.PatchQueryIncludingCursorField(query, streamCursorField) + fromBrowserMessageAsMap["payload"] = payload + } } if strings.Contains(query, "_aggregate") && strings.Contains(query, "aggregate {") { @@ -72,7 +88,11 @@ RangeLoop: browserConnection.ActiveSubscriptionsMutex.Lock() browserConnection.ActiveSubscriptions[queryId] = common.GraphQlSubscription{ Id: queryId, - Message: fromBrowserMessage, + Message: fromBrowserMessageAsMap, + OperationName: operationName, + StreamCursorField: streamCursorField, + StreamCursorVariableName: streamCursorVariableName, + StreamCursorCurrValue: streamCursorInitialValue, LastSeenOnHasuraConnetion: hc.Id, JsonPatchSupported: jsonPatchSupported, Type: messageType, @@ -96,11 +116,11 @@ RangeLoop: } if fromBrowserMessageAsMap["type"] == "connection_init" { - browserConnection.ConnectionInitMessage = fromBrowserMessage + browserConnection.ConnectionInitMessage = fromBrowserMessageAsMap } - log.Tracef("sending to hasura: %v", fromBrowserMessage) - err := wsjson.Write(hc.Context, hc.Websocket, fromBrowserMessage) + log.Tracef("sending to hasura: %v", fromBrowserMessageAsMap) + err := wsjson.Write(hc.Context, hc.Websocket, fromBrowserMessageAsMap) if err != nil { log.Errorf("error on write (we're disconnected from hasura): %v", err) return diff --git a/bbb-graphql-middleware/internal/hascli/retransmiter/retransmiter.go b/bbb-graphql-middleware/internal/hascli/retransmiter/retransmiter.go index 5cb1bb7cd9..530d167e42 100644 --- a/bbb-graphql-middleware/internal/hascli/retransmiter/retransmiter.go +++ b/bbb-graphql-middleware/internal/hascli/retransmiter/retransmiter.go @@ -11,8 +11,14 @@ func RetransmitSubscriptionStartMessages(hc *common.HasuraConnection, fromBrowse hc.Browserconn.ActiveSubscriptionsMutex.RLock() for _, subscription := range hc.Browserconn.ActiveSubscriptions { if subscription.LastSeenOnHasuraConnetion != hc.Id { + log.Tracef("retransmiting subscription start: %v", subscription.Message) - fromBrowserToHasuraChannel.Send(subscription.Message) + + if subscription.Type == common.Streaming && subscription.StreamCursorCurrValue != nil { + fromBrowserToHasuraChannel.Send(common.PatchQuerySettingLastCursorValue(subscription)) + } else { + fromBrowserToHasuraChannel.Send(subscription.Message) + } } } hc.Browserconn.ActiveSubscriptionsMutex.RUnlock() From c8bea83de85904717966320749235843448748c7 Mon Sep 17 00:00:00 2001 From: Guilherme Pereira Leme <69865537+GuiLeme@users.noreply.github.com> Date: Tue, 23 Jan 2024 17:34:50 -0300 Subject: [PATCH 0226/1039] feat(plugin): refactor name of general exensible areas interface (#19467) * [plugin-sdk-issue-62] - refactor general extensible area interface * [plugin-sdk-issue-62] - refactor last components and bump SDK version --- .../buttons/LiveSelection.tsx | 4 ++-- .../components/action-bar/manager.tsx | 8 +++---- .../action-button-dropdown/manager.tsx | 8 +++---- .../audio-settings-dropdown/manager.tsx | 8 +++---- .../camera-settings-dropdown/manager.tsx | 8 +++---- .../components/nav-bar/manager.tsx | 8 +++---- .../components/options-dropdown/manager.tsx | 8 +++---- .../presentation-dropdown/manager.tsx | 8 +++---- .../presentation-toolbar/manager.tsx | 8 +++---- .../user-camera-dropdown/manager.tsx | 8 +++---- .../components/user-list-dropdown/manager.tsx | 8 +++---- .../manager.tsx | 8 +++---- .../plugins-engine/extensible-areas/types.ts | 22 +++++++++---------- .../list-item/component.tsx | 10 ++++----- .../user-actions/component.tsx | 14 ++++++------ bigbluebutton-html5/package-lock.json | 6 ++--- bigbluebutton-html5/package.json | 2 +- 17 files changed, 73 insertions(+), 73 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/LiveSelection.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/LiveSelection.tsx index f81930500c..518f28b5ec 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/LiveSelection.tsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/LiveSelection.tsx @@ -95,7 +95,7 @@ export const LiveSelection: React.FC = ({ const { pluginsExtensibleAreasAggregatedState, } = useContext(PluginsContext); - let audioSettingsDropdownItems = [] as PluginSdk.AudioSettingsDropdownItem[]; + let audioSettingsDropdownItems = [] as PluginSdk.AudioSettingsDropdownInterface[]; if (pluginsExtensibleAreasAggregatedState.audioSettingsDropdownItems) { audioSettingsDropdownItems = [ ...pluginsExtensibleAreasAggregatedState.audioSettingsDropdownItems, @@ -209,7 +209,7 @@ export const LiveSelection: React.FC = ({ .concat(leaveAudioOption); audioSettingsDropdownItems.forEach((audioSettingsDropdownItem: - PluginSdk.AudioSettingsDropdownItem) => { + PluginSdk.AudioSettingsDropdownInterface) => { switch (audioSettingsDropdownItem.type) { case AudioSettingsDropdownItemType.OPTION: { const audioSettingsDropdownOption = audioSettingsDropdownItem as PluginSdk.AudioSettingsDropdownOption; diff --git a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/action-bar/manager.tsx b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/action-bar/manager.tsx index 59dbea3692..8247ebe9ea 100644 --- a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/action-bar/manager.tsx +++ b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/action-bar/manager.tsx @@ -16,7 +16,7 @@ const ActionBarPluginStateContainer = (( const [ actionBarItems, setActionBarItems, - ] = useState([]); + ] = useState([]); const { pluginsExtensibleAreasAggregatedState, @@ -29,7 +29,7 @@ const ActionBarPluginStateContainer = (( // Update context with computed aggregated list of all plugin provided toolbar items const aggregatedActionBarItems = ( - [] as PluginSdk.ActionsBarItem[]).concat( + [] as PluginSdk.ActionsBarInterface[]).concat( ...Object.values(extensibleAreaMap) .map((extensibleArea: ExtensibleArea) => extensibleArea.actionsBarItems), ); @@ -41,8 +41,8 @@ const ActionBarPluginStateContainer = (( ); }, [actionBarItems]); - pluginApi.setActionsBarItems = (items: PluginSdk.ActionsBarItem[]) => { - const itemsWithId = items.map(generateItemWithId) as PluginSdk.ActionsBarItem[]; + pluginApi.setActionsBarItems = (items: PluginSdk.ActionsBarInterface[]) => { + const itemsWithId = items.map(generateItemWithId) as PluginSdk.ActionsBarInterface[]; return setActionBarItems(itemsWithId); }; return null; diff --git a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/action-button-dropdown/manager.tsx b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/action-button-dropdown/manager.tsx index 1879d935c0..41b0a64e56 100644 --- a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/action-button-dropdown/manager.tsx +++ b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/action-button-dropdown/manager.tsx @@ -19,7 +19,7 @@ const ActionButtonDropdownPluginStateContainer = (( const [ actionButtonDropdownItems, setActionButtonDropdownItems, - ] = useState([]); + ] = useState([]); const { pluginsExtensibleAreasAggregatedState, @@ -32,7 +32,7 @@ const ActionButtonDropdownPluginStateContainer = (( // Update context with computed aggregated list of all plugin provided toolbar items const aggregatedActionButtonDropdownItems = ( - [] as PluginSdk.ActionButtonDropdownItem[]).concat( + [] as PluginSdk.ActionButtonDropdownInterface[]).concat( ...Object.values(extensibleAreaMap) .map((extensibleArea: ExtensibleArea) => extensibleArea.actionButtonDropdownItems), ); @@ -44,8 +44,8 @@ const ActionButtonDropdownPluginStateContainer = (( ); }, [actionButtonDropdownItems]); - pluginApi.setActionButtonDropdownItems = (items: PluginSdk.ActionButtonDropdownItem[]) => { - const itemsWithId = items.map(generateItemWithId) as PluginSdk.ActionButtonDropdownItem[]; + pluginApi.setActionButtonDropdownItems = (items: PluginSdk.ActionButtonDropdownInterface[]) => { + const itemsWithId = items.map(generateItemWithId) as PluginSdk.ActionButtonDropdownInterface[]; return setActionButtonDropdownItems(itemsWithId); }; return null; diff --git a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/audio-settings-dropdown/manager.tsx b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/audio-settings-dropdown/manager.tsx index f13097dbe2..6363454385 100644 --- a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/audio-settings-dropdown/manager.tsx +++ b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/audio-settings-dropdown/manager.tsx @@ -18,7 +18,7 @@ const AudioSettingsDropdownPluginStateContainer = (( const [ audioSettingsDropdownItems, setAudioSettingsDropdownItems, - ] = useState([]); + ] = useState([]); const { pluginsExtensibleAreasAggregatedState, @@ -30,7 +30,7 @@ const AudioSettingsDropdownPluginStateContainer = (( extensibleAreaMap[uuid].audioSettingsDropdownItems = audioSettingsDropdownItems; // Update context with computed aggregated list of all plugin provided toolbar items - const aggregatedAudioSettingsDropdownItems = ([] as PluginSdk.AudioSettingsDropdownItem[]).concat( + const aggregatedAudioSettingsDropdownItems = ([] as PluginSdk.AudioSettingsDropdownInterface[]).concat( ...Object.values(extensibleAreaMap) .map((extensibleArea: ExtensibleArea) => extensibleArea.audioSettingsDropdownItems), ); @@ -43,8 +43,8 @@ const AudioSettingsDropdownPluginStateContainer = (( ); }, [audioSettingsDropdownItems]); - pluginApi.setAudioSettingsDropdownItems = (items: PluginSdk.AudioSettingsDropdownItem[]) => { - const itemsWithId = items.map(generateItemWithId) as PluginSdk.AudioSettingsDropdownItem[]; + pluginApi.setAudioSettingsDropdownItems = (items: PluginSdk.AudioSettingsDropdownInterface[]) => { + const itemsWithId = items.map(generateItemWithId) as PluginSdk.AudioSettingsDropdownInterface[]; return setAudioSettingsDropdownItems(itemsWithId); }; return null; diff --git a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/camera-settings-dropdown/manager.tsx b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/camera-settings-dropdown/manager.tsx index fe54d5857d..763da23b93 100644 --- a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/camera-settings-dropdown/manager.tsx +++ b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/camera-settings-dropdown/manager.tsx @@ -19,7 +19,7 @@ const CameraSettingsDropdownPluginStateContainer = (( const [ cameraSettingsDropdownItems, setCameraSettingsDropdownItems, - ] = useState([]); + ] = useState([]); const { pluginsExtensibleAreasAggregatedState, @@ -32,7 +32,7 @@ const CameraSettingsDropdownPluginStateContainer = (( // Update context with computed aggregated list of all plugin provided toolbar items const aggregatedCameraSettingsDropdownItems = ( - [] as PluginSdk.CameraSettingsDropdownItem[]).concat( + [] as PluginSdk.CameraSettingsDropdownInterface[]).concat( ...Object.values(extensibleAreaMap) .map((extensibleArea: ExtensibleArea) => extensibleArea.cameraSettingsDropdownItems), ); @@ -44,8 +44,8 @@ const CameraSettingsDropdownPluginStateContainer = (( ); }, [cameraSettingsDropdownItems]); - pluginApi.setCameraSettingsDropdownItems = (items: PluginSdk.CameraSettingsDropdownItem[]) => { - const itemsWithId = items.map(generateItemWithId) as PluginSdk.CameraSettingsDropdownItem[]; + pluginApi.setCameraSettingsDropdownItems = (items: PluginSdk.CameraSettingsDropdownInterface[]) => { + const itemsWithId = items.map(generateItemWithId) as PluginSdk.CameraSettingsDropdownInterface[]; return setCameraSettingsDropdownItems(itemsWithId); }; return null; diff --git a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/nav-bar/manager.tsx b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/nav-bar/manager.tsx index 74bc494266..09ccc431d1 100644 --- a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/nav-bar/manager.tsx +++ b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/nav-bar/manager.tsx @@ -19,7 +19,7 @@ const NavBarPluginStateContainer = (( const [ navBarItems, setNavBarItems, - ] = useState([]); + ] = useState([]); const { pluginsExtensibleAreasAggregatedState, @@ -31,7 +31,7 @@ const NavBarPluginStateContainer = (( extensibleAreaMap[uuid].navBarItems = navBarItems; // Update context with computed aggregated list of all plugin provided toolbar items - const aggregatedNavBarItems = ([] as PluginSdk.NavBarItem[]).concat( + const aggregatedNavBarItems = ([] as PluginSdk.NavBarInterface[]).concat( ...Object.values(extensibleAreaMap) .map((extensibleArea: ExtensibleArea) => extensibleArea.navBarItems), ); @@ -44,8 +44,8 @@ const NavBarPluginStateContainer = (( ); }, [navBarItems]); - pluginApi.setNavBarItems = (items: PluginSdk.NavBarItem[]) => { - const itemsWithId = items.map(generateItemWithId) as PluginSdk.NavBarItem[]; + pluginApi.setNavBarItems = (items: PluginSdk.NavBarInterface[]) => { + const itemsWithId = items.map(generateItemWithId) as PluginSdk.NavBarInterface[]; return setNavBarItems(itemsWithId); }; return null; diff --git a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/options-dropdown/manager.tsx b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/options-dropdown/manager.tsx index 17b9962595..b6cf48727c 100644 --- a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/options-dropdown/manager.tsx +++ b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/options-dropdown/manager.tsx @@ -19,7 +19,7 @@ const OptionsDropdownPluginStateContainer = (( const [ optionsDropdownItems, setOptionsDropdownItems, - ] = useState([]); + ] = useState([]); const { pluginsExtensibleAreasAggregatedState, @@ -32,7 +32,7 @@ const OptionsDropdownPluginStateContainer = (( // Update context with computed aggregated list of all plugin provided toolbar items const aggregatedOptionsDropdownItems = ( - [] as PluginSdk.OptionsDropdownItem[]).concat( + [] as PluginSdk.OptionsDropdownInterface[]).concat( ...Object.values(extensibleAreaMap) .map((extensibleArea: ExtensibleArea) => extensibleArea.optionsDropdownItems), ); @@ -44,8 +44,8 @@ const OptionsDropdownPluginStateContainer = (( ); }, [optionsDropdownItems]); - pluginApi.setOptionsDropdownItems = (items: PluginSdk.OptionsDropdownItem[]) => { - const itemsWithId = items.map(generateItemWithId) as PluginSdk.OptionsDropdownItem[]; + pluginApi.setOptionsDropdownItems = (items: PluginSdk.OptionsDropdownInterface[]) => { + const itemsWithId = items.map(generateItemWithId) as PluginSdk.OptionsDropdownInterface[]; return setOptionsDropdownItems(itemsWithId); }; return null; diff --git a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/presentation-dropdown/manager.tsx b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/presentation-dropdown/manager.tsx index d126270137..c8d97c66ca 100644 --- a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/presentation-dropdown/manager.tsx +++ b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/presentation-dropdown/manager.tsx @@ -19,7 +19,7 @@ const PresentationDropdownPluginStateContainer = (( const [ presentationDropdownItems, setPresentationDropdownItems, - ] = useState([]); + ] = useState([]); const { pluginsExtensibleAreasAggregatedState, @@ -32,7 +32,7 @@ const PresentationDropdownPluginStateContainer = (( // Update context with computed aggregated list of all plugin provided toolbar items const aggregatedPresentationDropdownItems = ( - [] as PluginSdk.PresentationDropdownItem[]).concat( + [] as PluginSdk.PresentationDropdownInterface[]).concat( ...Object.values(extensibleAreaMap) .map((extensibleArea: ExtensibleArea) => extensibleArea.presentationDropdownItems), ); @@ -44,8 +44,8 @@ const PresentationDropdownPluginStateContainer = (( ); }, [presentationDropdownItems]); - pluginApi.setPresentationDropdownItems = (items: PluginSdk.PresentationDropdownItem[]) => { - const itemsWithId = items.map(generateItemWithId) as PluginSdk.PresentationDropdownItem[]; + pluginApi.setPresentationDropdownItems = (items: PluginSdk.PresentationDropdownInterface[]) => { + const itemsWithId = items.map(generateItemWithId) as PluginSdk.PresentationDropdownInterface[]; return setPresentationDropdownItems(itemsWithId); }; return null; diff --git a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/presentation-toolbar/manager.tsx b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/presentation-toolbar/manager.tsx index ed1324fe6d..cd3e87aff4 100644 --- a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/presentation-toolbar/manager.tsx +++ b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/presentation-toolbar/manager.tsx @@ -19,7 +19,7 @@ const PresentationToolbarPluginStateContainer = (( const [ presentationToolbarItems, setPresentationToolbarItems, - ] = useState([]); + ] = useState([]); const { pluginsExtensibleAreasAggregatedState, @@ -31,7 +31,7 @@ const PresentationToolbarPluginStateContainer = (( extensibleAreaMap[uuid].presentationToolbarItems = presentationToolbarItems; // Update context with computed aggregated list of all plugin provided toolbar items - const aggregatedPresentationToolbarItems = ([] as PluginSdk.PresentationToolbarItem[]).concat( + const aggregatedPresentationToolbarItems = ([] as PluginSdk.PresentationToolbarInterface[]).concat( ...Object.values(extensibleAreaMap) .map((extensibleArea: ExtensibleArea) => extensibleArea.presentationToolbarItems), ); @@ -44,8 +44,8 @@ const PresentationToolbarPluginStateContainer = (( ); }, [presentationToolbarItems]); - pluginApi.setPresentationToolbarItems = (items: PluginSdk.PresentationToolbarItem[]) => { - const itemsWithId = items.map(generateItemWithId) as PluginSdk.PresentationToolbarItem[]; + pluginApi.setPresentationToolbarItems = (items: PluginSdk.PresentationToolbarInterface[]) => { + const itemsWithId = items.map(generateItemWithId) as PluginSdk.PresentationToolbarInterface[]; return setPresentationToolbarItems(itemsWithId); }; return null; diff --git a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/user-camera-dropdown/manager.tsx b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/user-camera-dropdown/manager.tsx index 7b3105e82a..34363b6fc0 100644 --- a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/user-camera-dropdown/manager.tsx +++ b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/user-camera-dropdown/manager.tsx @@ -19,7 +19,7 @@ const UserCameraDropdownPluginStateContainer = (( const [ userCameraDropdownItems, setUserCameraDropdownItems, - ] = useState([]); + ] = useState([]); const { pluginsExtensibleAreasAggregatedState, @@ -32,7 +32,7 @@ const UserCameraDropdownPluginStateContainer = (( // Update context with computed aggregated list of all plugin provided toolbar items const aggregatedUserCameraDropdownItems = ( - [] as PluginSdk.UserCameraDropdownItem[]).concat( + [] as PluginSdk.UserCameraDropdownInterface[]).concat( ...Object.values(extensibleAreaMap) .map((extensibleArea: ExtensibleArea) => extensibleArea.userCameraDropdownItems), ); @@ -44,8 +44,8 @@ const UserCameraDropdownPluginStateContainer = (( ); }, [userCameraDropdownItems]); - pluginApi.setUserCameraDropdownItems = (items: PluginSdk.UserCameraDropdownItem[]) => { - const itemsWithId = items.map(generateItemWithId) as PluginSdk.UserCameraDropdownItem[]; + pluginApi.setUserCameraDropdownItems = (items: PluginSdk.UserCameraDropdownInterface[]) => { + const itemsWithId = items.map(generateItemWithId) as PluginSdk.UserCameraDropdownInterface[]; return setUserCameraDropdownItems(itemsWithId); }; return null; diff --git a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/user-list-dropdown/manager.tsx b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/user-list-dropdown/manager.tsx index 176e2a5655..6e1f58f0d4 100644 --- a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/user-list-dropdown/manager.tsx +++ b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/user-list-dropdown/manager.tsx @@ -19,7 +19,7 @@ const UserListDropdownPluginStateContainer = (( const [ userListDropdownItems, setUserListDropdownItems, - ] = useState([]); + ] = useState([]); const { pluginsExtensibleAreasAggregatedState, @@ -32,7 +32,7 @@ const UserListDropdownPluginStateContainer = (( // Update context with computed aggregated list of all plugin provided toolbar items const aggregatedUserListDropdownItems = ( - [] as PluginSdk.UserListDropdownItem[]).concat( + [] as PluginSdk.UserListDropdownInterface[]).concat( ...Object.values(extensibleAreaMap) .map((extensibleArea: ExtensibleArea) => extensibleArea.userListDropdownItems), ); @@ -44,8 +44,8 @@ const UserListDropdownPluginStateContainer = (( ); }, [userListDropdownItems]); - pluginApi.setUserListDropdownItems = (items: PluginSdk.UserListDropdownItem[]) => { - const itemsWithId = items.map(generateItemWithId) as PluginSdk.UserListDropdownItem[]; + pluginApi.setUserListDropdownItems = (items: PluginSdk.UserListDropdownInterface[]) => { + const itemsWithId = items.map(generateItemWithId) as PluginSdk.UserListDropdownInterface[]; return setUserListDropdownItems(itemsWithId); }; return null; diff --git a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/user-list-item-additional-information/manager.tsx b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/user-list-item-additional-information/manager.tsx index 28ed7f3bca..4eed563f82 100644 --- a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/user-list-item-additional-information/manager.tsx +++ b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/components/user-list-item-additional-information/manager.tsx @@ -19,7 +19,7 @@ const UserListItemAdditionalInformationPluginStateContainer = (( const [ userListItemAdditionalInformation, setUserListItemAdditionalInformation, - ] = useState([]); + ] = useState([]); const { pluginsExtensibleAreasAggregatedState, @@ -32,7 +32,7 @@ const UserListItemAdditionalInformationPluginStateContainer = (( // Update context with computed aggregated list of all plugin provided toolbar items const aggregatedUserListItemAdditionalInformation = ( - [] as PluginSdk.UserListItemAdditionalInformation[]).concat( + [] as PluginSdk.UserListItemAdditionalInformationInterface[]).concat( ...Object.values(extensibleAreaMap) .map((extensibleArea: ExtensibleArea) => extensibleArea.userListItemAdditionalInformation), ); @@ -44,8 +44,8 @@ const UserListItemAdditionalInformationPluginStateContainer = (( ); }, [userListItemAdditionalInformation]); - pluginApi.setUserListItemAdditionalInformation = (items: PluginSdk.UserListItemAdditionalInformation[]) => { - const itemsWithId = items.map(generateItemWithId) as PluginSdk.UserListItemAdditionalInformation[]; + pluginApi.setUserListItemAdditionalInformation = (items: PluginSdk.UserListItemAdditionalInformationInterface[]) => { + const itemsWithId = items.map(generateItemWithId) as PluginSdk.UserListItemAdditionalInformationInterface[]; return setUserListItemAdditionalInformation(itemsWithId); }; return null; diff --git a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/types.ts b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/types.ts index a43bb3825c..a64a506b16 100644 --- a/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/types.ts +++ b/bigbluebutton-html5/imports/ui/components/plugins-engine/extensible-areas/types.ts @@ -8,17 +8,17 @@ export interface ExtensibleAreaStateManagerProps { } export interface ExtensibleArea { - presentationToolbarItems: PluginSdk.PresentationToolbarItem[]; - userListDropdownItems: PluginSdk.UserListDropdownItem[]; - actionButtonDropdownItems: PluginSdk.ActionButtonDropdownItem[]; - audioSettingsDropdownItems: PluginSdk.AudioSettingsDropdownItem[]; - actionsBarItems: PluginSdk.ActionsBarItem[]; - presentationDropdownItems: PluginSdk.PresentationDropdownItem[]; - navBarItems: PluginSdk.NavBarItem[]; - optionsDropdownItems: PluginSdk.OptionsDropdownItem[]; - cameraSettingsDropdownItems: PluginSdk.CameraSettingsDropdownItem[]; - userCameraDropdownItems: PluginSdk.UserCameraDropdownItem[]; - userListItemAdditionalInformation: PluginSdk.UserListItemAdditionalInformation[]; + presentationToolbarItems: PluginSdk.PresentationToolbarInterface[]; + userListDropdownItems: PluginSdk.UserListDropdownInterface[]; + actionButtonDropdownItems: PluginSdk.ActionButtonDropdownInterface[]; + audioSettingsDropdownItems: PluginSdk.AudioSettingsDropdownInterface[]; + actionsBarItems: PluginSdk.ActionsBarInterface[]; + presentationDropdownItems: PluginSdk.PresentationDropdownInterface[]; + navBarItems: PluginSdk.NavBarInterface[]; + optionsDropdownItems: PluginSdk.OptionsDropdownInterface[]; + cameraSettingsDropdownItems: PluginSdk.CameraSettingsDropdownInterface[]; + userCameraDropdownItems: PluginSdk.UserCameraDropdownInterface[]; + userListItemAdditionalInformation: PluginSdk.UserListItemAdditionalInformationInterface[]; floatingWindows: PluginSdk.FloatingWindowInterface[] } diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/list-item/component.tsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/list-item/component.tsx index 1b0d641694..d33595331d 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/list-item/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/list-item/component.tsx @@ -76,10 +76,10 @@ interface UserListItemProps { } const renderUserListItemIconsFromPlugin = ( - userItemsFromPlugin: PluginSdk.UserListItemAdditionalInformation[], + userItemsFromPlugin: PluginSdk.UserListItemAdditionalInformationInterface[], ) => userItemsFromPlugin.filter( (item) => item.type === UserListItemAdditionalInformationType.ICON, -).map((item: PluginSdk.UserListItemAdditionalInformation) => { +).map((item: PluginSdk.UserListItemAdditionalInformationInterface) => { const itemToRender = item as PluginSdk.UserListItemIcon; return ( = ({ emoji, native, size }) => ( const UserListItem: React.FC = ({ user, lockSettings }) => { const { pluginsExtensibleAreasAggregatedState } = useContext(PluginsContext); - let userItemsFromPlugin = [] as PluginSdk.UserListItemAdditionalInformation[]; + let userItemsFromPlugin = [] as PluginSdk.UserListItemAdditionalInformationInterface[]; if (pluginsExtensibleAreasAggregatedState.userListItemAdditionalInformation) { userItemsFromPlugin = pluginsExtensibleAreasAggregatedState.userListItemAdditionalInformation.filter((item) => { - const userListItem = item as PluginSdk.UserListItemAdditionalInformation; + const userListItem = item as PluginSdk.UserListItemAdditionalInformationInterface; return userListItem.userId === user.userId; - }) as PluginSdk.UserListItemAdditionalInformation[]; + }) as PluginSdk.UserListItemAdditionalInformationInterface[]; } const intl = useIntl(); diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/component.tsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/component.tsx index a0cabefa57..4583f022f0 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/component.tsx @@ -151,10 +151,10 @@ const messages = defineMessages({ }, }); const makeDropdownPluginItem: ( - userDropdownItems: PluginSdk.UserListDropdownItem[]) => DropdownItem[] = ( - userDropdownItems: PluginSdk.UserListDropdownItem[], + userDropdownItems: PluginSdk.UserListDropdownInterface[]) => DropdownItem[] = ( + userDropdownItems: PluginSdk.UserListDropdownInterface[], ) => userDropdownItems.map( - (userDropdownItem: PluginSdk.UserListDropdownItem) => { + (userDropdownItem: PluginSdk.UserListDropdownInterface) => { const returnValue: DropdownItem = { isSeparator: false, key: userDropdownItem.id, @@ -272,7 +272,7 @@ const UserActions: React.FC = ({ && lockSettings.hasActiveLockSetting && !user.isModerator; - let userListDropdownItems = [] as PluginSdk.UserListDropdownItem[]; + let userListDropdownItems = [] as PluginSdk.UserListDropdownInterface[]; if (pluginsExtensibleAreasAggregatedState.userListDropdownItems) { userListDropdownItems = [ ...pluginsExtensibleAreasAggregatedState.userListDropdownItems, @@ -280,7 +280,7 @@ const UserActions: React.FC = ({ } const userDropdownItems = userListDropdownItems.filter( - (item: PluginSdk.UserListDropdownItem) => (user?.userId === item?.userId), + (item: PluginSdk.UserListDropdownInterface) => (user?.userId === item?.userId), ); const hasWhiteboardAccess = user.presPagesWritable?.length > 0; @@ -316,7 +316,7 @@ const UserActions: React.FC = ({ const dropdownOptions = [ ...makeDropdownPluginItem(userDropdownItems.filter( - (item: PluginSdk.UserListDropdownItem) => (item?.type === UserListDropdownItemType.INFORMATION), + (item: PluginSdk.UserListDropdownInterface) => (item?.type === UserListDropdownItemType.INFORMATION), )), { allowed: allowedToChangeStatus, @@ -556,7 +556,7 @@ const UserActions: React.FC = ({ icon: 'time', }, ...makeDropdownPluginItem(userDropdownItems.filter( - (item: PluginSdk.UserListDropdownItem) => (item?.type !== UserListDropdownItemType.INFORMATION), + (item: PluginSdk.UserListDropdownInterface) => (item?.type !== UserListDropdownItemType.INFORMATION), )), ]; diff --git a/bigbluebutton-html5/package-lock.json b/bigbluebutton-html5/package-lock.json index d3a81e4834..09e6671d21 100644 --- a/bigbluebutton-html5/package-lock.json +++ b/bigbluebutton-html5/package-lock.json @@ -3418,9 +3418,9 @@ "dev": true }, "bigbluebutton-html-plugin-sdk": { - "version": "0.0.32", - "resolved": "https://registry.npmjs.org/bigbluebutton-html-plugin-sdk/-/bigbluebutton-html-plugin-sdk-0.0.32.tgz", - "integrity": "sha512-VDMSFwUFox/z5G9P8aeAafFVATX+mCPpDxb+r43qaDa7ZeABATvQ1KKhgQ+/L5JLm9QP6IzWiGVFVWHDjd6x4A==", + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/bigbluebutton-html-plugin-sdk/-/bigbluebutton-html-plugin-sdk-0.0.34.tgz", + "integrity": "sha512-quIzZP/a5rmGJiFnJeZucKF0ix4vxWWCLKF4HM7QxJ+HVb5g6BGDcI42TY/c5yoc8Ylmv5jOJNiF9OAXf1W/sw==", "requires": { "@apollo/client": "^3.8.7" } diff --git a/bigbluebutton-html5/package.json b/bigbluebutton-html5/package.json index 5a67275380..425a577bce 100644 --- a/bigbluebutton-html5/package.json +++ b/bigbluebutton-html5/package.json @@ -47,7 +47,7 @@ "autoprefixer": "^10.4.4", "axios": "^1.6.0", "babel-runtime": "~6.26.0", - "bigbluebutton-html-plugin-sdk": "0.0.32", + "bigbluebutton-html-plugin-sdk": "0.0.34", "bowser": "^2.11.0", "browser-bunyan": "^1.8.0", "classnames": "^2.2.6", From 47fdc4a87889f138112e4465f4efad5f3bc30627 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Tue, 23 Jan 2024 19:32:11 -0300 Subject: [PATCH 0227/1039] Fix Graphql error --- .../internal/common/SafeChannel.go | 21 ++++++++++++++++--- .../internal/common/types.go | 1 + .../internal/hascli/client.go | 15 ++++++------- .../internal/hascli/conn/reader/reader.go | 15 ++++++++++--- .../internal/hascli/conn/writer/writer.go | 13 +++++++++++- .../internal/websrv/connhandler.go | 9 ++++---- .../internal/websrv/reader/reader.go | 11 +++++----- 7 files changed, 62 insertions(+), 23 deletions(-) diff --git a/bbb-graphql-middleware/internal/common/SafeChannel.go b/bbb-graphql-middleware/internal/common/SafeChannel.go index 527576443e..fdc11c0784 100644 --- a/bbb-graphql-middleware/internal/common/SafeChannel.go +++ b/bbb-graphql-middleware/internal/common/SafeChannel.go @@ -5,9 +5,10 @@ import ( ) type SafeChannel struct { - ch chan interface{} - closed bool - mux sync.Mutex + ch chan interface{} + closed bool + mux sync.Mutex + freezeFlag bool } func NewSafeChannel(size int) *SafeChannel { @@ -45,3 +46,17 @@ func (s *SafeChannel) Close() { s.closed = true } } + +func (s *SafeChannel) FreezeChannel() { + if !s.freezeFlag { + s.mux.Lock() + s.freezeFlag = true + } +} + +func (s *SafeChannel) UnfreezeChannel() { + if s.freezeFlag { + s.mux.Unlock() + s.freezeFlag = false + } +} diff --git a/bbb-graphql-middleware/internal/common/types.go b/bbb-graphql-middleware/internal/common/types.go index 15bc7e5db5..f3ea4e1c83 100644 --- a/bbb-graphql-middleware/internal/common/types.go +++ b/bbb-graphql-middleware/internal/common/types.go @@ -38,6 +38,7 @@ type BrowserConnection struct { ConnectionInitMessage map[string]interface{} // init message received in this connection (to be used on hasura reconnect) HasuraConnection *HasuraConnection // associated hasura connection Disconnected bool // indicate if the connection is gone + ConnAckSentToBrowser bool // indicate if `connection_ack` msg was already sent to the browser } type HasuraConnection struct { diff --git a/bbb-graphql-middleware/internal/hascli/client.go b/bbb-graphql-middleware/internal/hascli/client.go index 224a066615..5bf445fb1f 100644 --- a/bbb-graphql-middleware/internal/hascli/client.go +++ b/bbb-graphql-middleware/internal/hascli/client.go @@ -67,7 +67,13 @@ func HasuraClient(browserConnection *common.BrowserConnection, cookies []*http.C } browserConnection.HasuraConnection = &thisConnection - defer func() { browserConnection.HasuraConnection = nil }() + defer func() { + browserConnection.HasuraConnection = nil + + //It's necessary to freeze the channel to avoid client trying to start subscriptions before Hasura connection is initialised + //It will unfreeze after `connection_ack` is sent by Hasura + fromBrowserToHasuraChannel.FreezeChannel() + }() // Make the connection c, _, err := websocket.Dial(hasuraConnectionContext, hasuraEndpoint, &dialOptions) @@ -90,16 +96,11 @@ func HasuraClient(browserConnection *common.BrowserConnection, cookies []*http.C // Start routines // reads from browser, writes to hasura - go writer.HasuraConnectionWriter(&thisConnection, fromBrowserToHasuraChannel, &wg) + go writer.HasuraConnectionWriter(&thisConnection, fromBrowserToHasuraChannel, &wg, browserConnection.ConnectionInitMessage) // reads from hasura, writes to browser go reader.HasuraConnectionReader(&thisConnection, fromHasuraToBrowserChannel, fromBrowserToHasuraChannel, &wg) - // if it's a reconnect, inject authentication - if !browserConnection.Disconnected && browserConnection.ConnectionInitMessage != nil { - fromBrowserToHasuraChannel.Send(browserConnection.ConnectionInitMessage) - } - // Wait wg.Wait() diff --git a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go index c66a299c35..07b01089f7 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go +++ b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go @@ -80,13 +80,22 @@ func HasuraConnectionReader(hc *common.HasuraConnection, fromHasuraToBrowserChan } } - // Write the message to browser - fromHasuraToBrowserChannel.Send(messageAsMap) - // Retransmit the subscription start commands when hasura confirms the connection // this is useful in case of a connection invalidation if messageType == "connection_ack" { + //Hasura connection was initialized, now it's able to send new messages to Hasura + fromBrowserToHasuraChannel.UnfreezeChannel() + + //Avoid to send `connection_ack` to the browser when it's a reconnection + if hc.Browserconn.ConnAckSentToBrowser == false { + fromHasuraToBrowserChannel.Send(messageAsMap) + hc.Browserconn.ConnAckSentToBrowser = true + } + go retransmiter.RetransmitSubscriptionStartMessages(hc, fromBrowserToHasuraChannel) + } else { + // Forward the message to browser + fromHasuraToBrowserChannel.Send(messageAsMap) } } } diff --git a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go index 334bc3c01a..9d7ea23837 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go +++ b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go @@ -12,7 +12,7 @@ import ( // HasuraConnectionWriter // process messages (middleware to hasura) -func HasuraConnectionWriter(hc *common.HasuraConnection, fromBrowserToHasuraChannel *common.SafeChannel, wg *sync.WaitGroup) { +func HasuraConnectionWriter(hc *common.HasuraConnection, fromBrowserToHasuraChannel *common.SafeChannel, wg *sync.WaitGroup, initMessage map[string]interface{}) { log := log.WithField("_routine", "HasuraConnectionWriter") browserConnection := hc.Browserconn @@ -23,6 +23,17 @@ func HasuraConnectionWriter(hc *common.HasuraConnection, fromBrowserToHasuraChan defer hc.ContextCancelFunc() defer log.Debugf("finished") + //Send authentication (init) message at first + //It will not use the channel (fromBrowserToHasuraChannel) because this msg must bypass ChannelFreeze + if initMessage != nil { + log.Infof("it's a reconnection, injecting authentication (init) message") + err := wsjson.Write(hc.Context, hc.Websocket, initMessage) + if err != nil { + log.Errorf("error on write authentication (init) message (we're disconnected from hasura): %v", err) + return + } + } + RangeLoop: for { select { diff --git a/bbb-graphql-middleware/internal/websrv/connhandler.go b/bbb-graphql-middleware/internal/websrv/connhandler.go index 1e25a30cbc..038c755b6a 100644 --- a/bbb-graphql-middleware/internal/websrv/connhandler.go +++ b/bbb-graphql-middleware/internal/websrv/connhandler.go @@ -54,9 +54,10 @@ func ConnectionHandler(w http.ResponseWriter, r *http.Request) { defer c.Close(websocket.StatusInternalError, "the sky is falling") var thisConnection = common.BrowserConnection{ - Id: browserConnectionId, - ActiveSubscriptions: make(map[string]common.GraphQlSubscription, 1), - Context: browserConnectionContext, + Id: browserConnectionId, + ActiveSubscriptions: make(map[string]common.GraphQlSubscription, 1), + Context: browserConnectionContext, + ConnAckSentToBrowser: false, } BrowserConnectionsMutex.Lock() @@ -97,8 +98,8 @@ func ConnectionHandler(w http.ResponseWriter, r *http.Request) { BrowserConnectionsMutex.RLock() thisBrowserConnection := BrowserConnections[browserConnectionId] BrowserConnectionsMutex.RUnlock() - log.Debugf("created hasura client") if thisBrowserConnection != nil { + log.Debugf("created hasura client") hascli.HasuraClient(thisBrowserConnection, r.Cookies(), fromBrowserToHasuraChannel, fromHasuraToBrowserChannel) } time.Sleep(100 * time.Millisecond) diff --git a/bbb-graphql-middleware/internal/websrv/reader/reader.go b/bbb-graphql-middleware/internal/websrv/reader/reader.go index 9ba7a8aca4..2915ff5381 100644 --- a/bbb-graphql-middleware/internal/websrv/reader/reader.go +++ b/bbb-graphql-middleware/internal/websrv/reader/reader.go @@ -10,14 +10,14 @@ import ( "time" ) -func BrowserConnectionReader(browserConnectionId string, ctx context.Context, c *websocket.Conn, fromBrowserToHasuraChannel1 *common.SafeChannel, fromBrowserToHasuraChannel2 *common.SafeChannel, waitGroups []*sync.WaitGroup) { +func BrowserConnectionReader(browserConnectionId string, ctx context.Context, c *websocket.Conn, fromBrowserToHasuraChannel *common.SafeChannel, fromBrowserToHasuraConnectionEstablishingChannel *common.SafeChannel, waitGroups []*sync.WaitGroup) { log := log.WithField("_routine", "BrowserConnectionReader").WithField("browserConnectionId", browserConnectionId) defer log.Debugf("finished") log.Debugf("starting") defer func() { - fromBrowserToHasuraChannel1.Close() - fromBrowserToHasuraChannel2.Close() + fromBrowserToHasuraChannel.Close() + fromBrowserToHasuraConnectionEstablishingChannel.Close() }() defer func() { @@ -41,8 +41,9 @@ func BrowserConnectionReader(browserConnectionId string, ctx context.Context, c } log.Tracef("received from browser: %v", v) + //fmt.Println("received from browser: %v", v) - fromBrowserToHasuraChannel1.Send(v) - fromBrowserToHasuraChannel2.Send(v) + fromBrowserToHasuraChannel.Send(v) + fromBrowserToHasuraConnectionEstablishingChannel.Send(v) } } From fab48cc1b6e9bea1d8aed091eb8d559650099d5f Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Tue, 23 Jan 2024 20:20:16 -0300 Subject: [PATCH 0228/1039] Fix Graphql error `start received before the connection is initialised` (#19497) --- .../internal/common/SafeChannel.go | 21 ++++++++++++++++--- .../internal/common/types.go | 1 + .../internal/hascli/client.go | 15 ++++++------- .../internal/hascli/conn/reader/reader.go | 15 ++++++++++--- .../internal/hascli/conn/writer/writer.go | 13 +++++++++++- .../internal/websrv/connhandler.go | 9 ++++---- .../internal/websrv/reader/reader.go | 11 +++++----- 7 files changed, 62 insertions(+), 23 deletions(-) diff --git a/bbb-graphql-middleware/internal/common/SafeChannel.go b/bbb-graphql-middleware/internal/common/SafeChannel.go index 527576443e..fdc11c0784 100644 --- a/bbb-graphql-middleware/internal/common/SafeChannel.go +++ b/bbb-graphql-middleware/internal/common/SafeChannel.go @@ -5,9 +5,10 @@ import ( ) type SafeChannel struct { - ch chan interface{} - closed bool - mux sync.Mutex + ch chan interface{} + closed bool + mux sync.Mutex + freezeFlag bool } func NewSafeChannel(size int) *SafeChannel { @@ -45,3 +46,17 @@ func (s *SafeChannel) Close() { s.closed = true } } + +func (s *SafeChannel) FreezeChannel() { + if !s.freezeFlag { + s.mux.Lock() + s.freezeFlag = true + } +} + +func (s *SafeChannel) UnfreezeChannel() { + if s.freezeFlag { + s.mux.Unlock() + s.freezeFlag = false + } +} diff --git a/bbb-graphql-middleware/internal/common/types.go b/bbb-graphql-middleware/internal/common/types.go index 15bc7e5db5..f3ea4e1c83 100644 --- a/bbb-graphql-middleware/internal/common/types.go +++ b/bbb-graphql-middleware/internal/common/types.go @@ -38,6 +38,7 @@ type BrowserConnection struct { ConnectionInitMessage map[string]interface{} // init message received in this connection (to be used on hasura reconnect) HasuraConnection *HasuraConnection // associated hasura connection Disconnected bool // indicate if the connection is gone + ConnAckSentToBrowser bool // indicate if `connection_ack` msg was already sent to the browser } type HasuraConnection struct { diff --git a/bbb-graphql-middleware/internal/hascli/client.go b/bbb-graphql-middleware/internal/hascli/client.go index 224a066615..5bf445fb1f 100644 --- a/bbb-graphql-middleware/internal/hascli/client.go +++ b/bbb-graphql-middleware/internal/hascli/client.go @@ -67,7 +67,13 @@ func HasuraClient(browserConnection *common.BrowserConnection, cookies []*http.C } browserConnection.HasuraConnection = &thisConnection - defer func() { browserConnection.HasuraConnection = nil }() + defer func() { + browserConnection.HasuraConnection = nil + + //It's necessary to freeze the channel to avoid client trying to start subscriptions before Hasura connection is initialised + //It will unfreeze after `connection_ack` is sent by Hasura + fromBrowserToHasuraChannel.FreezeChannel() + }() // Make the connection c, _, err := websocket.Dial(hasuraConnectionContext, hasuraEndpoint, &dialOptions) @@ -90,16 +96,11 @@ func HasuraClient(browserConnection *common.BrowserConnection, cookies []*http.C // Start routines // reads from browser, writes to hasura - go writer.HasuraConnectionWriter(&thisConnection, fromBrowserToHasuraChannel, &wg) + go writer.HasuraConnectionWriter(&thisConnection, fromBrowserToHasuraChannel, &wg, browserConnection.ConnectionInitMessage) // reads from hasura, writes to browser go reader.HasuraConnectionReader(&thisConnection, fromHasuraToBrowserChannel, fromBrowserToHasuraChannel, &wg) - // if it's a reconnect, inject authentication - if !browserConnection.Disconnected && browserConnection.ConnectionInitMessage != nil { - fromBrowserToHasuraChannel.Send(browserConnection.ConnectionInitMessage) - } - // Wait wg.Wait() diff --git a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go index c66a299c35..07b01089f7 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go +++ b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go @@ -80,13 +80,22 @@ func HasuraConnectionReader(hc *common.HasuraConnection, fromHasuraToBrowserChan } } - // Write the message to browser - fromHasuraToBrowserChannel.Send(messageAsMap) - // Retransmit the subscription start commands when hasura confirms the connection // this is useful in case of a connection invalidation if messageType == "connection_ack" { + //Hasura connection was initialized, now it's able to send new messages to Hasura + fromBrowserToHasuraChannel.UnfreezeChannel() + + //Avoid to send `connection_ack` to the browser when it's a reconnection + if hc.Browserconn.ConnAckSentToBrowser == false { + fromHasuraToBrowserChannel.Send(messageAsMap) + hc.Browserconn.ConnAckSentToBrowser = true + } + go retransmiter.RetransmitSubscriptionStartMessages(hc, fromBrowserToHasuraChannel) + } else { + // Forward the message to browser + fromHasuraToBrowserChannel.Send(messageAsMap) } } } diff --git a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go index 334bc3c01a..9d7ea23837 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go +++ b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go @@ -12,7 +12,7 @@ import ( // HasuraConnectionWriter // process messages (middleware to hasura) -func HasuraConnectionWriter(hc *common.HasuraConnection, fromBrowserToHasuraChannel *common.SafeChannel, wg *sync.WaitGroup) { +func HasuraConnectionWriter(hc *common.HasuraConnection, fromBrowserToHasuraChannel *common.SafeChannel, wg *sync.WaitGroup, initMessage map[string]interface{}) { log := log.WithField("_routine", "HasuraConnectionWriter") browserConnection := hc.Browserconn @@ -23,6 +23,17 @@ func HasuraConnectionWriter(hc *common.HasuraConnection, fromBrowserToHasuraChan defer hc.ContextCancelFunc() defer log.Debugf("finished") + //Send authentication (init) message at first + //It will not use the channel (fromBrowserToHasuraChannel) because this msg must bypass ChannelFreeze + if initMessage != nil { + log.Infof("it's a reconnection, injecting authentication (init) message") + err := wsjson.Write(hc.Context, hc.Websocket, initMessage) + if err != nil { + log.Errorf("error on write authentication (init) message (we're disconnected from hasura): %v", err) + return + } + } + RangeLoop: for { select { diff --git a/bbb-graphql-middleware/internal/websrv/connhandler.go b/bbb-graphql-middleware/internal/websrv/connhandler.go index 1e25a30cbc..038c755b6a 100644 --- a/bbb-graphql-middleware/internal/websrv/connhandler.go +++ b/bbb-graphql-middleware/internal/websrv/connhandler.go @@ -54,9 +54,10 @@ func ConnectionHandler(w http.ResponseWriter, r *http.Request) { defer c.Close(websocket.StatusInternalError, "the sky is falling") var thisConnection = common.BrowserConnection{ - Id: browserConnectionId, - ActiveSubscriptions: make(map[string]common.GraphQlSubscription, 1), - Context: browserConnectionContext, + Id: browserConnectionId, + ActiveSubscriptions: make(map[string]common.GraphQlSubscription, 1), + Context: browserConnectionContext, + ConnAckSentToBrowser: false, } BrowserConnectionsMutex.Lock() @@ -97,8 +98,8 @@ func ConnectionHandler(w http.ResponseWriter, r *http.Request) { BrowserConnectionsMutex.RLock() thisBrowserConnection := BrowserConnections[browserConnectionId] BrowserConnectionsMutex.RUnlock() - log.Debugf("created hasura client") if thisBrowserConnection != nil { + log.Debugf("created hasura client") hascli.HasuraClient(thisBrowserConnection, r.Cookies(), fromBrowserToHasuraChannel, fromHasuraToBrowserChannel) } time.Sleep(100 * time.Millisecond) diff --git a/bbb-graphql-middleware/internal/websrv/reader/reader.go b/bbb-graphql-middleware/internal/websrv/reader/reader.go index 9ba7a8aca4..2915ff5381 100644 --- a/bbb-graphql-middleware/internal/websrv/reader/reader.go +++ b/bbb-graphql-middleware/internal/websrv/reader/reader.go @@ -10,14 +10,14 @@ import ( "time" ) -func BrowserConnectionReader(browserConnectionId string, ctx context.Context, c *websocket.Conn, fromBrowserToHasuraChannel1 *common.SafeChannel, fromBrowserToHasuraChannel2 *common.SafeChannel, waitGroups []*sync.WaitGroup) { +func BrowserConnectionReader(browserConnectionId string, ctx context.Context, c *websocket.Conn, fromBrowserToHasuraChannel *common.SafeChannel, fromBrowserToHasuraConnectionEstablishingChannel *common.SafeChannel, waitGroups []*sync.WaitGroup) { log := log.WithField("_routine", "BrowserConnectionReader").WithField("browserConnectionId", browserConnectionId) defer log.Debugf("finished") log.Debugf("starting") defer func() { - fromBrowserToHasuraChannel1.Close() - fromBrowserToHasuraChannel2.Close() + fromBrowserToHasuraChannel.Close() + fromBrowserToHasuraConnectionEstablishingChannel.Close() }() defer func() { @@ -41,8 +41,9 @@ func BrowserConnectionReader(browserConnectionId string, ctx context.Context, c } log.Tracef("received from browser: %v", v) + //fmt.Println("received from browser: %v", v) - fromBrowserToHasuraChannel1.Send(v) - fromBrowserToHasuraChannel2.Send(v) + fromBrowserToHasuraChannel.Send(v) + fromBrowserToHasuraConnectionEstablishingChannel.Send(v) } } From 6b87c06b4cec1e06e8f35ba4e91c402b2a7b11f9 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Tue, 23 Jan 2024 20:24:09 -0300 Subject: [PATCH 0229/1039] Add some examples to Graphql client test --- bbb-graphql-client-test/src/Annotations.js | 40 ++++++++++--------- bbb-graphql-client-test/src/Auth.js | 25 +----------- bbb-graphql-client-test/src/CursorsStream.js | 3 +- .../src/UserConnectionStatus.js | 2 +- bbb-graphql-client-test/src/UserList.js | 30 +++++++++++--- 5 files changed, 52 insertions(+), 48 deletions(-) diff --git a/bbb-graphql-client-test/src/Annotations.js b/bbb-graphql-client-test/src/Annotations.js index a2531380dc..90e4f8dcd9 100644 --- a/bbb-graphql-client-test/src/Annotations.js +++ b/bbb-graphql-client-test/src/Annotations.js @@ -2,20 +2,24 @@ import {useSubscription, gql, useQuery} from '@apollo/client'; import React, { useState } from "react"; import usePatchedSubscription from "./usePatchedSubscription"; +export const CURRENT_PAGE_ANNOTATIONS_STREAM = gql`subscription annotationsStream($lastUpdatedAt: timestamptz){ + pres_annotation_curr_stream(batch_size: 10, cursor: {initial_value: {lastUpdatedAt: $lastUpdatedAt}}) { + annotationId annotationInfo pageId + presentationId + userId + } +}`; + export default function Annotations() { - const { loading, error, data } = usePatchedSubscription( - gql`subscription { - pres_annotation_curr_stream(batch_size: 10, cursor: {initial_value: {lastUpdatedAt: "\\"2023-03-29T20:26:29.002\\""}}) { - annotationId - annotationInfo - lastUpdatedAt - pageId - presentationId - userId - } - } - ` - ); + const lastUpdatedAt = "2023-03-29T20:26:29.002"; + + const { loading, error, data } = useSubscription( + CURRENT_PAGE_ANNOTATIONS_STREAM, + { + variables: { lastUpdatedAt }, + }, + ); + return !loading && !error && (
Private Chat MessagesCursor
userIdIdName extIddurationdurationInSeconds
{user.userId}{curr.name} {curr.extId}{curr.duration}{curr.durationInSeconds}
IduserId namejoinedStatus joinErrorCode joinErrorMessage
{curr.userId} {curr.name}{curr.joined ? 'Yes' : 'No'} - {curr.joined ? '' : } + {curr.joined && !curr.loggedOut && !curr.ejected ? 'joined' : ''} + {curr.loggedOut ? 'loggedOut' : ''} + {curr.ejected ? 'ejected' : ''} + {!curr.joined && !curr.loggedOut ? : ''} + {curr.joined && !curr.loggedOut && !curr.ejected ? : ''} {curr.joinErrorCode} {curr.joinErrorMessage} {user?.connectionStatus?.connectionAliveAt} {user.disconnected === true ? 'Yes' : 'No'}{user.loggedOut === true ? 'Yes' : 'No'}{user.loggedOut === true ? 'Yes' : 'No'} + {user.isModerator ? : ''} +
{JSON.stringify(curr.payloadJson)} {JSON.stringify(curr.toRoles)} {curr.createdAt} + +
+ + handleCheckboxChange(option.optionId)} + checked={checkedAnswers.includes(option.optionId)} + ariaLabelledBy={`pollAnswerLabel${option.optionDesc}`} + ariaDescribedBy={`pollAnswerDesc${option.optionDesc}`} + /> + +
@@ -24,24 +28,24 @@ export default function Annotations() { + - - - {data.map((curr) => { + + {data.pres_annotation_curr_stream.map((curr) => { console.log('pres_annotation_curr_stream', curr); return ( {/**/} + - ); })} - +
Annotations Stream (Full object)
lastUpdatedAt annotationId annotationInfolastUpdatedAt
{user.userId}{curr.lastUpdatedAt} {curr.annotationId} {curr.annotationInfo}{curr.lastUpdatedAt}
); } diff --git a/bbb-graphql-client-test/src/Auth.js b/bbb-graphql-client-test/src/Auth.js index 3c9965b596..e5099f15b1 100644 --- a/bbb-graphql-client-test/src/Auth.js +++ b/bbb-graphql-client-test/src/Auth.js @@ -78,6 +78,7 @@ export default function Auth() { loggedOut ejected isOnline + isModerator joined joinErrorCode joinErrorMessage @@ -93,12 +94,6 @@ export default function Auth() { }` ); - console.log("data"); - console.log(data); - console.log("error"); - console.log(error); - console.log(loading); - if(!loading && !error) { if(!data.hasOwnProperty('user_current') || @@ -156,10 +151,9 @@ export default function Auth() {
- -
+

@@ -190,21 +184,6 @@ export default function Auth() {
- - // return ( - // - // {curr.userId} - // {curr.name} - // {curr.joined && !curr.loggedOut && !curr.ejected ? 'joined' : ''} - // {curr.loggedOut ? 'loggedOut' : ''} - // {curr.ejected ? 'ejected' : ''} - // {!curr.joined && !curr.loggedOut ? : ''} - // {curr.joined && !curr.loggedOut && !curr.ejected ? : ''} - // - // {curr.joinErrorCode} - // {curr.joinErrorMessage} - // - // ); } diff --git a/bbb-graphql-client-test/src/CursorsStream.js b/bbb-graphql-client-test/src/CursorsStream.js index 31cba461d2..7cef428f29 100644 --- a/bbb-graphql-client-test/src/CursorsStream.js +++ b/bbb-graphql-client-test/src/CursorsStream.js @@ -3,8 +3,9 @@ import {useSubscription, gql, useQuery} from '@apollo/client'; export default function CursorsStream() { const { loading, error, data } = useSubscription( + //2023-03-29T20:26:29.002 gql`subscription { - pres_page_cursor_stream(batch_size: 10, cursor: {initial_value: {lastUpdatedAt: "\\"2023-03-29T20:26:29.002\\""}}) { + pres_page_cursor_stream(batch_size: 10, cursor: { initial_value: { lastUpdatedAt: "2024-01-20T13:12:20.945+00:00" } }) { isCurrentPage lastUpdatedAt pageId diff --git a/bbb-graphql-client-test/src/UserConnectionStatus.js b/bbb-graphql-client-test/src/UserConnectionStatus.js index cc5ed9c404..a949891764 100644 --- a/bbb-graphql-client-test/src/UserConnectionStatus.js +++ b/bbb-graphql-client-test/src/UserConnectionStatus.js @@ -90,7 +90,7 @@ export default function UserConnectionStatus() { {data.user_connectionStatus.map((curr) => { - console.log('user_connectionStatus', curr); + // console.log('user_connectionStatus', curr); if(curr.userClientResponseAt == null) { // handleUpdateUserClientResponseAt(); diff --git a/bbb-graphql-client-test/src/UserList.js b/bbb-graphql-client-test/src/UserList.js index 64bdaafccb..97ccf7fdfb 100644 --- a/bbb-graphql-client-test/src/UserList.js +++ b/bbb-graphql-client-test/src/UserList.js @@ -14,12 +14,12 @@ const ParentOfUserList = ({user}) => { setShouldRender(e.target.checked); } }> - {shouldRender && } + {shouldRender && } ); } -function UserList({userId}) { +function UserList({myUser}) { const [dispatchUserEject] = useMutation(gql` mutation UserEject($userId: String!) { @@ -38,6 +38,22 @@ function UserList({userId}) { }); }; + const [dispatchUserSetPresenter] = useMutation(gql` + mutation UserEject($userId: String!) { + userSetPresenter( + userId: $userId + ) + } + `); + + const handleDispatchUserSetPresenter = (userId) => { + dispatchUserSetPresenter({ + variables: { + userId: userId, + }, + }); + }; + const { loading, error, data } = usePatchedSubscription( gql`subscription { user(limit: 50, order_by: [ @@ -113,17 +129,20 @@ function UserList({userId}) { {data.map((user) => { - console.log('user', user); + // console.log('user', user); return ( {/*{user.userId}*/}
{user.name}
+ {myUser.userId == user.userId ? (You!) : ''} {user.role} {user.emoji} {user.avatar} - {user.presenter === true ? 'Yes' : 'No'} + {user.presenter === true ? 'Yes' : 'No'} + {myUser.isModerator && !user.presenter ? : ''} + {user.mobile === true ? 'Yes' : 'No'} {user.clientType} 0 ? '#A0DAA9' : ''}}>{user.cameras.length > 0 ? 'Yes' : 'No'} @@ -140,7 +159,8 @@ function UserList({userId}) { {user?.connectionStatus?.connectionAliveAt} {user.disconnected === true ? 'Yes' : 'No'} {user.loggedOut === true ? 'Yes' : 'No'} - {user.isModerator ? : ''} +
+ {myUser.isModerator ? : ''} ); From caeebfd109012f4b04bca232a5d37059c2b82b57 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Tue, 23 Jan 2024 20:47:43 -0300 Subject: [PATCH 0230/1039] Make graphql-action to send annotations expect type json instead of string --- bbb-graphql-server/metadata/actions.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbb-graphql-server/metadata/actions.graphql b/bbb-graphql-server/metadata/actions.graphql index cd01bfb053..6277092c23 100644 --- a/bbb-graphql-server/metadata/actions.graphql +++ b/bbb-graphql-server/metadata/actions.graphql @@ -276,7 +276,7 @@ type Mutation { type Mutation { presAnnotationSubmit( pageId: String! - annotations: [String]! + annotations: json! ): Boolean } From d3b2242c7f15485f3afc6052b4e87552722d3091 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Tue, 23 Jan 2024 20:55:20 -0300 Subject: [PATCH 0231/1039] Validate if annotations is an valid Array --- bbb-graphql-actions/src/actions/presAnnotationSubmit.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bbb-graphql-actions/src/actions/presAnnotationSubmit.ts b/bbb-graphql-actions/src/actions/presAnnotationSubmit.ts index 803538e173..a9129c93ae 100644 --- a/bbb-graphql-actions/src/actions/presAnnotationSubmit.ts +++ b/bbb-graphql-actions/src/actions/presAnnotationSubmit.ts @@ -17,6 +17,10 @@ export default function buildRedisMessage(sessionVariables: Record Date: Wed, 24 Jan 2024 08:37:51 -0300 Subject: [PATCH 0232/1039] migrate sendBulkAnnotations action --- .../imports/api/annotations/server/index.js | 1 - .../imports/api/annotations/server/methods.js | 8 ----- .../server/methods/sendAnnotationHelper.js | 34 ------------------- .../server/methods/sendAnnotations.js | 17 ---------- .../server/methods/sendBulkAnnotations.js | 22 ------------ .../ui/components/presentation/mutations.jsx | 10 ++++++ .../ui/components/whiteboard/component.jsx | 10 +++--- .../ui/components/whiteboard/container.jsx | 22 ++++++++++-- .../ui/components/whiteboard/service.js | 14 ++++---- .../imports/ui/components/whiteboard/utils.js | 6 ---- bigbluebutton-html5/server/main.js | 1 - 11 files changed, 41 insertions(+), 104 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/annotations/server/index.js delete mode 100644 bigbluebutton-html5/imports/api/annotations/server/methods.js delete mode 100755 bigbluebutton-html5/imports/api/annotations/server/methods/sendAnnotationHelper.js delete mode 100755 bigbluebutton-html5/imports/api/annotations/server/methods/sendAnnotations.js delete mode 100644 bigbluebutton-html5/imports/api/annotations/server/methods/sendBulkAnnotations.js diff --git a/bigbluebutton-html5/imports/api/annotations/server/index.js b/bigbluebutton-html5/imports/api/annotations/server/index.js deleted file mode 100644 index ba55c4a16e..0000000000 --- a/bigbluebutton-html5/imports/api/annotations/server/index.js +++ /dev/null @@ -1 +0,0 @@ -import './methods'; diff --git a/bigbluebutton-html5/imports/api/annotations/server/methods.js b/bigbluebutton-html5/imports/api/annotations/server/methods.js deleted file mode 100644 index a2721ec9e9..0000000000 --- a/bigbluebutton-html5/imports/api/annotations/server/methods.js +++ /dev/null @@ -1,8 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import sendAnnotations from './methods/sendAnnotations'; -import sendBulkAnnotations from './methods/sendBulkAnnotations'; - -Meteor.methods({ - sendAnnotations, - sendBulkAnnotations, -}); diff --git a/bigbluebutton-html5/imports/api/annotations/server/methods/sendAnnotationHelper.js b/bigbluebutton-html5/imports/api/annotations/server/methods/sendAnnotationHelper.js deleted file mode 100755 index 3b39236edd..0000000000 --- a/bigbluebutton-html5/imports/api/annotations/server/methods/sendAnnotationHelper.js +++ /dev/null @@ -1,34 +0,0 @@ -import RedisPubSub from '/imports/startup/server/redis'; -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; -import Logger from '/imports/startup/server/logger'; - -export default function sendAnnotationHelper(annotations, meetingId, requesterUserId) { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'SendWhiteboardAnnotationsPubMsg'; - - try { - check(annotations, Array); - // TODO see if really necessary, don't know if it's possible - // to have annotations from different pages - // group annotations by same whiteboardId - const groupedAnnotations = annotations.reduce((r, v, i, a, k = v.wbId) => ((r[k] || (r[k] = [])).push(v), r), {}) //groupBy wbId - - Object.entries(groupedAnnotations).forEach(([_, whiteboardAnnotations]) => { - const whiteboardId = whiteboardAnnotations[0].wbId; - check(whiteboardId, String); - - const payload = { - whiteboardId, - annotations: whiteboardAnnotations, - html5InstanceId: parseInt(process.env.INSTANCE_ID, 10) || 1, - }; - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - }); - - } catch (err) { - Logger.error(`Exception while invoking method sendAnnotationHelper ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/api/annotations/server/methods/sendAnnotations.js b/bigbluebutton-html5/imports/api/annotations/server/methods/sendAnnotations.js deleted file mode 100755 index 530515f592..0000000000 --- a/bigbluebutton-html5/imports/api/annotations/server/methods/sendAnnotations.js +++ /dev/null @@ -1,17 +0,0 @@ -import { check } from 'meteor/check'; -import sendAnnotationHelper from './sendAnnotationHelper'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import Logger from '/imports/startup/server/logger'; - -export default function sendAnnotations(annotations) { - try { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - - sendAnnotationHelper(annotations, meetingId, requesterUserId); - } catch (err) { - Logger.error(`Exception while invoking method sendAnnotation ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/api/annotations/server/methods/sendBulkAnnotations.js b/bigbluebutton-html5/imports/api/annotations/server/methods/sendBulkAnnotations.js deleted file mode 100644 index a650f6f91f..0000000000 --- a/bigbluebutton-html5/imports/api/annotations/server/methods/sendBulkAnnotations.js +++ /dev/null @@ -1,22 +0,0 @@ -import { extractCredentials } from '/imports/api/common/server/helpers'; -import sendAnnotationHelper from './sendAnnotationHelper'; -import { check } from 'meteor/check'; -import Logger from '/imports/startup/server/logger'; - -export default function sendBulkAnnotations(payload) { - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - try { - check(meetingId, String); - check(requesterUserId, String); - - console.log("!!!!!!! sendBulkAnnotations!!!!:", payload) - - sendAnnotationHelper(payload, meetingId, requesterUserId); - //payload.forEach((annotation) => sendAnnotationHelper(annotation, meetingId, requesterUserId)); - return true; - } catch (err) { - Logger.error(`Exception while invoking method sendBulkAnnotations ${err.stack}`); - return false; - } -} diff --git a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx index 4a6d9aecfb..79910f22df 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx @@ -70,6 +70,15 @@ export const PRES_ANNOTATION_DELETE = gql` } `; +export const PRES_ANNOTATION_SUBMIT = gql` + mutation PresAnnotationSubmit($pageId: String!, $annotations: json!) { + presAnnotationSubmit( + pageId: $pageId, + annotations: $annotations, + ) + } +`; + export default { PRESENTATION_SET_ZOOM, PRESENTATION_SET_WRITERS, @@ -78,4 +87,5 @@ export default { PRESENTATION_SET_CURRENT, PRESENTATION_REMOVE, PRES_ANNOTATION_DELETE, + PRES_ANNOTATION_SUBMIT, }; diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx index a06223b32f..cf4c69f4a7 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx @@ -99,7 +99,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) { isPresenter, removeShapes, initDefaultPages, - persistShape, + persistShapeWrapper, shapes, assets, currentUser, @@ -667,7 +667,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) { console.log("EDITOR : ", editor); - const debouncePersistShape = debounce({ delay: 0 }, persistShape); + const debouncePersistShape = debounce({ delay: 0 }, persistShapeWrapper); const colorStyles = ['black', 'blue', 'green', 'grey', 'light-blue', 'light-green', 'light-red', 'light-violet', 'orange', 'red', 'violet', 'yellow']; const dashStyles = ['dashed', 'dotted', 'draw', 'solid']; @@ -704,7 +704,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) { createdBy: currentUser?.userId, } }; - persistShape(updatedRecord, whiteboardId, isModerator); + persistShapeWrapper(updatedRecord, whiteboardId, isModerator); }); Object.values(updated).forEach(([_, record]) => { @@ -715,7 +715,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) { createdBy: shapes[record?.id]?.meta?.createdBy, } }; - persistShape(updatedRecord, whiteboardId, isModerator); + persistShapeWrapper(updatedRecord, whiteboardId, isModerator); }); Object.values(removed).forEach((record) => { @@ -889,7 +889,7 @@ Whiteboard.propTypes = { isIphone: PropTypes.bool.isRequired, removeShapes: PropTypes.func.isRequired, initDefaultPages: PropTypes.func.isRequired, - persistShape: PropTypes.func.isRequired, + persistShapeWrapper: PropTypes.func.isRequired, notifyNotAllowedChange: PropTypes.func.isRequired, shapes: PropTypes.objectOf(PropTypes.shape).isRequired, assets: PropTypes.objectOf(PropTypes.shape).isRequired, diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx index cf3b3fa3f9..4adba20bc4 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx @@ -32,7 +32,11 @@ import useMeeting from '/imports/ui/core/hooks/useMeeting'; import { AssetRecordType, } from "@tldraw/tldraw"; -import { PRESENTATION_SET_ZOOM, PRES_ANNOTATION_DELETE } from '../presentation/mutations'; +import { + PRESENTATION_SET_ZOOM, + PRES_ANNOTATION_DELETE, + PRES_ANNOTATION_SUBMIT, +} from '../presentation/mutations'; const WHITEBOARD_CONFIG = Meteor.settings.public.whiteboard; @@ -62,6 +66,7 @@ const WhiteboardContainer = (props) => { const [presentationSetZoom] = useMutation(PRESENTATION_SET_ZOOM); const [presentationDeleteAnnotations] = useMutation(PRES_ANNOTATION_DELETE); + const [presentationSubmitAnnotations] = useMutation(PRES_ANNOTATION_SUBMIT); const removeShapes = (shapeIds) => { presentationDeleteAnnotations({ @@ -88,6 +93,19 @@ const WhiteboardContainer = (props) => { }); }; + const submitAnnotations = async (newAnnotations) => { + await presentationSubmitAnnotations({ + variables: { + pageId: currentPresentationPage?.pageId, + annotations: newAnnotations, + }, + }); + }; + + const persistShapeWrapper = (shape, whiteboardId, isModerator) => { + persistShape(shape, whiteboardId, isModerator, submitAnnotations); + }; + const isMultiUserActive = whiteboardWriters?.length > 0; const { data: pollData } = useSubscription(POLL_RESULTS_SUBSCRIPTION); @@ -240,7 +258,7 @@ const WhiteboardContainer = (props) => { sidebarNavigationWidth, layoutContextDispatch, initDefaultPages, - persistShape, + persistShapeWrapper, isMultiUserActive, shapes, bgShape, diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/service.js b/bigbluebutton-html5/imports/ui/components/whiteboard/service.js index 1be7fb562f..7eb10cd5ee 100755 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/service.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/service.js @@ -1,11 +1,9 @@ import Auth from '/imports/ui/services/auth'; import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user'; -import { makeCall } from '/imports/ui/services/api'; import PollService from '/imports/ui/components/poll/service'; import { defineMessages } from 'react-intl'; import { notify } from '/imports/ui/services/notification'; import caseInsensitiveReducer from '/imports/utils/caseInsensitiveReducer'; -import { getTextSize } from './utils'; const intlMessages = defineMessages({ notifyNotAllowedChange: { @@ -30,7 +28,7 @@ const annotationsRetryDelay = 1000; let annotationsSenderIsRunning = false; -const proccessAnnotationsQueue = async () => { +const proccessAnnotationsQueue = async (submitAnnotations) => { annotationsSenderIsRunning = true; const queueSize = annotationsQueue.length; @@ -41,7 +39,7 @@ const proccessAnnotationsQueue = async () => { const annotations = annotationsQueue.splice(0, queueSize); - const isAnnotationSent = await makeCall('sendBulkAnnotations', annotations); + const isAnnotationSent = await submitAnnotations(annotations); if (!isAnnotationSent) { // undo splice @@ -58,7 +56,7 @@ const proccessAnnotationsQueue = async () => { } }; -const sendAnnotation = (annotation) => { +const sendAnnotation = (annotation, submitAnnotations) => { // Prevent sending annotations while disconnected // TODO: Change this to add the annotation, but delay the send until we're // reconnected. With this it will miss things @@ -70,7 +68,7 @@ const sendAnnotation = (annotation) => { } else { annotationsQueue.push(annotation); } - if (!annotationsSenderIsRunning) setTimeout(proccessAnnotationsQueue, annotationsBufferTimeMin); + if (!annotationsSenderIsRunning) setTimeout(() => proccessAnnotationsQueue(submitAnnotations), annotationsBufferTimeMin); }; const getMultiUser = (whiteboardId) => { @@ -87,7 +85,7 @@ const getMultiUser = (whiteboardId) => { return data.multiUser; }; -const persistShape = (shape, whiteboardId, isModerator) => { +const persistShape = (shape, whiteboardId, isModerator, submitAnnotations) => { const annotation = { id: shape.id, annotationInfo: { ...shape, isModerator }, @@ -95,7 +93,7 @@ const persistShape = (shape, whiteboardId, isModerator) => { userId: Auth.userID, }; - sendAnnotation(annotation); + sendAnnotation(annotation, submitAnnotations); }; const initDefaultPages = (count = 1) => { diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/utils.js b/bigbluebutton-html5/imports/ui/components/whiteboard/utils.js index f2fdc45d5c..ca2965bf3b 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/utils.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/utils.js @@ -1,10 +1,4 @@ import React from 'react'; -import { isEqual } from 'radash'; -import { - persistShape, - notifyNotAllowedChange, - notifyShapeNumberExceeded, -} from './service'; const WHITEBOARD_CONFIG = Meteor.settings.public.whiteboard; const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; diff --git a/bigbluebutton-html5/server/main.js b/bigbluebutton-html5/server/main.js index 1a33749f2f..c66e0f42de 100755 --- a/bigbluebutton-html5/server/main.js +++ b/bigbluebutton-html5/server/main.js @@ -3,7 +3,6 @@ import '/imports/startup/server'; // 2x import '/imports/api/meetings/server'; import '/imports/api/users/server'; -import '/imports/api/annotations/server'; import '/imports/api/cursor/server'; import '/imports/api/polls/server'; import '/imports/api/captions/server'; From b35833c25c6a84271ea6a883a92ff25deadf5eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Wed, 24 Jan 2024 09:18:42 -0300 Subject: [PATCH 0233/1039] fix send annotation --- .../ui/components/whiteboard/container.jsx | 4 ++- .../ui/components/whiteboard/service.js | 31 +++++++++++-------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx index f196b731f0..5199368d40 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx @@ -97,12 +97,14 @@ const WhiteboardContainer = (props) => { }; const submitAnnotations = async (newAnnotations) => { - await presentationSubmitAnnotations({ + const isAnnotationSent = await presentationSubmitAnnotations({ variables: { pageId: currentPresentationPage?.pageId, annotations: newAnnotations, }, }); + + return isAnnotationSent?.data?.presAnnotationSubmit; }; const persistShapeWrapper = (shape, whiteboardId, isModerator) => { diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/service.js b/bigbluebutton-html5/imports/ui/components/whiteboard/service.js index 7eb10cd5ee..db38ebb0af 100755 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/service.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/service.js @@ -39,20 +39,25 @@ const proccessAnnotationsQueue = async (submitAnnotations) => { const annotations = annotationsQueue.splice(0, queueSize); - const isAnnotationSent = await submitAnnotations(annotations); + try { + const isAnnotationSent = await submitAnnotations(annotations); - if (!isAnnotationSent) { - // undo splice + if (!isAnnotationSent) { + // undo splice + annotationsQueue.splice(0, 0, ...annotations); + setTimeout(() => proccessAnnotationsQueue(submitAnnotations), annotationsRetryDelay); + } else { + // ask tiago + const delayPerc = Math.min( + annotationsMaxDelayQueueSize, queueSize, + ) / annotationsMaxDelayQueueSize; + const delayDelta = annotationsBufferTimeMax - annotationsBufferTimeMin; + const delayTime = annotationsBufferTimeMin + delayDelta * delayPerc; + setTimeout(() => proccessAnnotationsQueue(submitAnnotations), delayTime); + } + } catch (error) { annotationsQueue.splice(0, 0, ...annotations); - setTimeout(proccessAnnotationsQueue, annotationsRetryDelay); - } else { - // ask tiago - const delayPerc = Math.min( - annotationsMaxDelayQueueSize, queueSize, - ) / annotationsMaxDelayQueueSize; - const delayDelta = annotationsBufferTimeMax - annotationsBufferTimeMin; - const delayTime = annotationsBufferTimeMin + delayDelta * delayPerc; - setTimeout(proccessAnnotationsQueue, delayTime); + setTimeout(() => proccessAnnotationsQueue(submitAnnotations), annotationsRetryDelay); } }; @@ -85,7 +90,7 @@ const getMultiUser = (whiteboardId) => { return data.multiUser; }; -const persistShape = (shape, whiteboardId, isModerator, submitAnnotations) => { +const persistShape = async (shape, whiteboardId, isModerator, submitAnnotations) => { const annotation = { id: shape.id, annotationInfo: { ...shape, isModerator }, From cd03c116b0e66b907f1c300b52ca8d754f2980d0 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Wed, 24 Jan 2024 11:33:38 -0300 Subject: [PATCH 0234/1039] Add configs from /enter to Graphql --- .../bigbluebutton/core/db/MeetingDAO.scala | 21 +++++++++++++++++++ .../common2/domain/Meeting2x.scala | 3 +++ .../org/bigbluebutton/api/MeetingService.java | 4 ++-- .../bigbluebutton/api2/IBbbWebApiGWApp.java | 3 +++ .../bigbluebutton/api2/BbbWebApiGWApp.scala | 14 ++++++++++++- bbb-graphql-server/bbb_schema.sql | 3 +++ .../tables/public_v_meeting.yaml | 7 +++++++ .../meetings/server/modifiers/addMeeting.js | 3 +++ 8 files changed, 55 insertions(+), 3 deletions(-) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/MeetingDAO.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/MeetingDAO.scala index ed8e9362da..08b0f0ccdd 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/MeetingDAO.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/MeetingDAO.scala @@ -20,6 +20,9 @@ case class MeetingDbModel( presentationUploadExternalUrl: String, learningDashboardAccessToken: String, logoutUrl: String, + customLogoUrl: Option[String], + bannerText: Option[String], + bannerColor: Option[String], createdTime: Long, durationInSeconds: Int ) @@ -38,6 +41,9 @@ class MeetingDbTableDef(tag: Tag) extends Table[MeetingDbModel](tag, None, "meet presentationUploadExternalUrl, learningDashboardAccessToken, logoutUrl, + customLogoUrl, + bannerText, + bannerColor, createdTime, durationInSeconds ) <> (MeetingDbModel.tupled, MeetingDbModel.unapply) @@ -53,6 +59,9 @@ class MeetingDbTableDef(tag: Tag) extends Table[MeetingDbModel](tag, None, "meet val presentationUploadExternalUrl = column[String]("presentationUploadExternalUrl") val learningDashboardAccessToken = column[String]("learningDashboardAccessToken") val logoutUrl = column[String]("logoutUrl") + val customLogoUrl = column[Option[String]]("customLogoUrl") + val bannerText = column[Option[String]]("bannerText") + val bannerColor = column[Option[String]]("bannerColor") val createdTime = column[Long]("createdTime") val durationInSeconds = column[Int]("durationInSeconds") } @@ -74,6 +83,18 @@ object MeetingDAO { presentationUploadExternalUrl = meetingProps.meetingProp.presentationUploadExternalUrl, learningDashboardAccessToken = meetingProps.password.learningDashboardAccessToken, logoutUrl = meetingProps.systemProps.logoutUrl, + customLogoUrl = meetingProps.systemProps.customLogoURL match { + case "" => None + case logoUrl => Some(logoUrl) + }, + bannerText = meetingProps.systemProps.bannerText match { + case "" => None + case bannerText => Some(bannerText) + }, + bannerColor = meetingProps.systemProps.bannerColor match { + case "" => None + case bannerColor => Some(bannerColor) + }, createdTime = meetingProps.durationProps.createdTime, durationInSeconds = meetingProps.durationProps.duration * 60 ) diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/domain/Meeting2x.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/domain/Meeting2x.scala index c729d6be73..17c03e9d05 100755 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/domain/Meeting2x.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/domain/Meeting2x.scala @@ -70,6 +70,9 @@ case class LockSettingsProps( case class SystemProps( html5InstanceId: Int, logoutUrl: String, + customLogoURL: String, + bannerText: String, + bannerColor: String, ) case class GroupProps( diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java index 92a860ec3d..7961d705d4 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java @@ -411,8 +411,8 @@ public class MeetingService implements MessageListener { m.getUserInactivityInspectTimerInMinutes(), m.getUserInactivityThresholdInMinutes(), m.getUserActivitySignResponseDelayInMinutes(), m.getEndWhenNoModerator(), m.getEndWhenNoModeratorDelayInMinutes(), m.getMuteOnStart(), m.getAllowModsToUnmuteUsers(), m.getAllowModsToEjectCameras(), m.getMeetingKeepEvents(), - m.breakoutRoomsParams, m.lockSettingsParams, m.getHtml5InstanceId(), m.getLogoutUrl(), - m.getGroups(), m.getDisabledFeatures(), m.getNotifyRecordingIsOn(), + m.breakoutRoomsParams, m.lockSettingsParams, m.getHtml5InstanceId(), m.getLogoutUrl(), m.getCustomLogoURL(), + m.getBannerText(), m.getBannerColor(), m.getGroups(), m.getDisabledFeatures(), m.getNotifyRecordingIsOn(), m.getPresentationUploadExternalDescription(), m.getPresentationUploadExternalUrl(), m.getOverrideClientSettings()); } diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api2/IBbbWebApiGWApp.java b/bbb-common-web/src/main/java/org/bigbluebutton/api2/IBbbWebApiGWApp.java index a9462f501e..f530eaba61 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api2/IBbbWebApiGWApp.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api2/IBbbWebApiGWApp.java @@ -43,6 +43,9 @@ public interface IBbbWebApiGWApp { LockSettingsParams lockSettingsParams, Integer html5InstanceId, String logoutUrl, + String customLogoURL, + String bannerText, + String bannerColor, ArrayList groups, ArrayList disabledFeatures, Boolean notifyRecordingIsOn, diff --git a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala index eafd968e2f..41af48826e 100755 --- a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala +++ b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala @@ -150,6 +150,9 @@ class BbbWebApiGWApp( lockSettingsParams: LockSettingsParams, html5InstanceId: java.lang.Integer, logoutUrl: String, + customLogoURL: String, + bannerText: String, + bannerColor: String, groups: java.util.ArrayList[Group], disabledFeatures: java.util.ArrayList[String], notifyRecordingIsOn: java.lang.Boolean, @@ -232,7 +235,16 @@ class BbbWebApiGWApp( val systemProps = SystemProps( html5InstanceId, - logoutUrl + logoutUrl, + customLogoURL, + bannerText match { + case t: String => t + case _ => "" + }, + bannerColor match { + case c: String => c + case _ => "" + }, ) val groupsAsVector: Vector[GroupProps] = groups.asScala.toVector.map(g => GroupProps(g.getGroupId(), g.getName(), g.getUsersExtId().asScala.toVector)) diff --git a/bbb-graphql-server/bbb_schema.sql b/bbb-graphql-server/bbb_schema.sql index 9e59dc9520..4c20e85869 100644 --- a/bbb-graphql-server/bbb_schema.sql +++ b/bbb-graphql-server/bbb_schema.sql @@ -21,6 +21,9 @@ create table "meeting" ( "learningDashboardAccessToken" varchar(100), "html5InstanceId" varchar(100), "logoutUrl" varchar(500), + "customLogoUrl" varchar(500), + "bannerText" text, + "bannerColor" varchar(50), "createdTime" bigint, "durationInSeconds" integer ); diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_meeting.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_meeting.yaml index 2edd87193b..a56af019ae 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_meeting.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_meeting.yaml @@ -138,13 +138,17 @@ select_permissions: - role: bbb_client permission: columns: + - bannerColor + - bannerText - createdAt - createdTime + - customLogoUrl - disabledFeatures - durationInSeconds - extId - html5InstanceId - isBreakout + - logoutUrl - maxPinnedCameras - meetingCameraCap - meetingId @@ -158,6 +162,9 @@ select_permissions: - role: pre_join_bbb_client permission: columns: + - bannerColor + - bannerText + - customLogoUrl - logoutUrl - meetingId - name diff --git a/bigbluebutton-html5/imports/api/meetings/server/modifiers/addMeeting.js b/bigbluebutton-html5/imports/api/meetings/server/modifiers/addMeeting.js index c0c794bf25..18ad2e112c 100755 --- a/bigbluebutton-html5/imports/api/meetings/server/modifiers/addMeeting.js +++ b/bigbluebutton-html5/imports/api/meetings/server/modifiers/addMeeting.js @@ -132,6 +132,9 @@ export default async function addMeeting(meeting) { systemProps: { html5InstanceId: Number, logoutUrl: String, + customLogoURL: String, + bannerText: String, + bannerColor: String, }, groups: Array, overrideClientSettings: String, From f839fd8578a3841a6ff69ce87661d58eab30559c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Wed, 24 Jan 2024 13:19:51 -0300 Subject: [PATCH 0235/1039] fix(whiteboard): prevent annotation subscription from being recreated --- .../ui/components/whiteboard/container.jsx | 71 +++++++------------ 1 file changed, 27 insertions(+), 44 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx index 26d8b07ed3..088a0fe71e 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx @@ -1,8 +1,7 @@ -import React from 'react'; -import { useQuery, useSubscription, useMutation } from '@apollo/client'; +import React, { useEffect, useState } from 'react'; +import { useSubscription, useMutation } from '@apollo/client'; import { CURRENT_PRESENTATION_PAGE_SUBSCRIPTION, - CURRENT_PAGE_ANNOTATIONS_QUERY, CURRENT_PAGE_ANNOTATIONS_STREAM, CURRENT_PAGE_WRITERS_SUBSCRIPTION, } from './queries'; @@ -39,9 +38,6 @@ import { PRESENTATION_SET_ZOOM } from '../presentation/mutations'; const WHITEBOARD_CONFIG = Meteor.settings.public.whiteboard; -let annotations = []; -let lastUpdatedAt = null; - const WhiteboardContainer = (props) => { const { intl, @@ -49,6 +45,8 @@ const WhiteboardContainer = (props) => { svgUri, } = props; + const [annotations, setAnnotations] = useState([]); + const meeting = useMeeting((m) => ({ lockSettings: m?.lockSettings, })); @@ -98,53 +96,38 @@ const WhiteboardContainer = (props) => { const { data: cursorData } = useSubscription(CURSOR_SUBSCRIPTION); const { pres_page_cursor: cursorArray } = (cursorData || []); - const { - loading: annotationsLoading, - data: annotationsData, - } = useQuery(CURRENT_PAGE_ANNOTATIONS_QUERY); - const { pres_annotation_curr: history } = (annotationsData || []); - - const lastHistoryTime = history?.[0]?.lastUpdatedAt || null; - - if (!lastUpdatedAt) { - if (lastHistoryTime) { - if (new Date(lastUpdatedAt).getTime() < new Date(lastHistoryTime).getTime()) { - lastUpdatedAt = lastHistoryTime; - } - } else { - const newLastUpdatedAt = new Date(); - lastUpdatedAt = newLastUpdatedAt.toISOString(); - } - } - - const { data: streamData } = useSubscription( + const { data: annotationStreamData, loading: annotationStreamLoading } = useSubscription( CURRENT_PAGE_ANNOTATIONS_STREAM, { - variables: { lastUpdatedAt }, + variables: { lastUpdatedAt: new Date(0).toISOString() }, }, ); - const { pres_annotation_curr_stream: streamDataItem } = (streamData || []); - if (streamDataItem) { - if (new Date(lastUpdatedAt).getTime() < new Date(streamDataItem[0].lastUpdatedAt).getTime()) { - if (streamDataItem[0].annotationInfo === '') { - // remove shape - annotations = annotations.filter( - (annotation) => annotation.annotationId !== streamDataItem[0].annotationId, - ); - } else { - // add shape - annotations = annotations.concat(streamDataItem); - } - lastUpdatedAt = streamDataItem[0].lastUpdatedAt; + useEffect(() => { + const { pres_annotation_curr_stream: annotationStream } = annotationStreamData || {}; + + if (annotationStream) { + const newAnnotations = []; + const annotationsToBeRemoved = []; + annotationStream.forEach((item) => { + if (item.annotationInfo === '') { + annotationsToBeRemoved.push(item.annotationId); + } else { + newAnnotations.push(item); + } + }); + const currentAnnotations = annotations.filter( + (annotation) => !annotationsToBeRemoved.includes(annotation.annotationId), + ); + setAnnotations([...currentAnnotations, ...newAnnotations]); } - } + }, [annotationStreamData]); + let shapes = {}; let bgShape = []; - if (!annotationsLoading && history) { - const pageAnnotations = history - .concat(annotations) + if (!annotationStreamLoading) { + const pageAnnotations = annotations .filter((annotation) => annotation.pageId === currentPresentationPage?.pageId); shapes = formatAnnotations(pageAnnotations, intl, curPageId, pollResults, currentPresentationPage); From 80c1a10cfd99cda16374194cd1d7bcf7f6a446c5 Mon Sep 17 00:00:00 2001 From: Anton B Date: Wed, 24 Jan 2024 13:47:34 -0300 Subject: [PATCH 0236/1039] fix: make RemainingTime a generic component and use separate containers for breakout and currentMeeting usages --- .../ui/components/breakout-room/component.jsx | 7 +- .../component.tsx | 252 ------------------ .../breakout-duration/component.tsx | 70 +++++ .../breakout-duration}/queries.ts | 0 .../common/remaining-time/component.tsx | 134 ++++++++++ .../meeting-duration/component.tsx | 85 ++++++ .../service.ts | 0 .../styles.ts | 0 .../notifications-bar/container.jsx | 10 +- .../breakout-room/component.jsx | 6 +- 10 files changed, 297 insertions(+), 267 deletions(-) delete mode 100644 bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/common/remaining-time/breakout-duration/component.tsx rename bigbluebutton-html5/imports/ui/components/common/{meeting-remaining-time-graphql => remaining-time/breakout-duration}/queries.ts (100%) create mode 100644 bigbluebutton-html5/imports/ui/components/common/remaining-time/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/common/remaining-time/meeting-duration/component.tsx rename bigbluebutton-html5/imports/ui/components/common/{meeting-remaining-time-graphql => remaining-time}/service.ts (100%) rename bigbluebutton-html5/imports/ui/components/common/{meeting-remaining-time-graphql => remaining-time}/styles.ts (100%) diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx b/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx index c422c01e19..0e850afc5c 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx @@ -4,7 +4,7 @@ import { Session } from 'meteor/session'; import logger from '/imports/startup/client/logger'; import Styled from './styles'; import Service from './service'; -import MeetingRemainingTime from '/imports/ui/components/common/meeting-remaining-time-graphql/component'; +import BreakoutRemainingTime from '/imports/ui/components/common/remaining-time/breakout-duration/component'; import MessageFormContainer from './message-form/container'; import VideoService from '/imports/ui/components/video-provider/service'; import { PANELS, ACTIONS } from '../layout/enums'; @@ -490,9 +490,8 @@ class BreakoutRoom extends PureComponent { ref={(ref) => this.durationContainerRef = ref} > - {amIModerator && visibleSetTimeForm ? ( diff --git a/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/component.tsx deleted file mode 100644 index 8e6b8e39d3..0000000000 --- a/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/component.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; -import humanizeSeconds from '/imports/utils/humanizeSeconds'; -import { setCapturedContentUploading } from './service'; -import { Text, Time } from './styles'; -import useMeeting from '/imports/ui/core/hooks/useMeeting'; -import { useSubscription } from '@apollo/client'; -import { FIRST_BREAKOUT_DURATION_DATA_SUBSCRIPTION, breakoutDataResponse } from './queries'; -import { notify } from '/imports/ui/services/notification'; -import { Meteor } from 'meteor/meteor'; -import useTimeSync from '/imports/ui/core/local-states/useTimeSync'; -import logger from '/imports/startup/client/logger'; - -const intlMessages = defineMessages({ - breakoutTimeRemaining: { - id: 'app.breakoutTimeRemainingMessage', - description: 'Message that tells how much time is remaining for the breakout room', - }, - breakoutDuration: { - id: 'app.createBreakoutRoom.duration', - description: 'breakout duration time', - }, - meetingTimeRemaining: { - id: 'app.meeting.meetingTimeRemaining', - description: 'Message that tells how much time is remaining for the meeting', - }, - breakoutWillClose: { - id: 'app.breakoutWillCloseMessage', - description: 'Message that tells time has ended and breakout will close', - }, - meetingWillClose: { - id: 'app.meeting.meetingTimeHasEnded', - description: 'Message that tells time has ended and meeting will close', - }, - calculatingBreakoutTimeRemaining: { - id: 'app.calculatingBreakoutTimeRemaining', - description: 'Message that tells that the remaining time is being calculated', - }, - alertBreakoutEndsUnderMinutes: { - id: 'app.meeting.alertBreakoutEndsUnderMinutes', - description: 'Alert that tells that the breakout ends under x minutes', - }, - alertMeetingEndsUnderMinutes: { - id: 'app.meeting.alertMeetingEndsUnderMinutes', - description: 'Alert that tells that the meeting ends under x minutes', - }, -}); - -interface MeetingRemainingTimeContainerProps { - isBreakoutDuration: boolean | false; - fromBreakoutPanel: boolean; - displayAlerts: boolean; -} - -interface MeetingRemainingTimeProps extends MeetingRemainingTimeContainerProps { - durationInSeconds: number; - referenceStartedTime: number; - isBreakout: boolean | false; -} - -const METEOR_SETTINGS_APP = Meteor.settings.public.app; -const REMAINING_TIME_ALERT_THRESHOLD_ARRAY: [number] = METEOR_SETTINGS_APP.remainingTimeAlertThresholdArray; - -let lastAlertTime: number | null = null; - -const MeetingRemainingTime: React.FC = (props) => { - const { - durationInSeconds, - isBreakoutDuration, - referenceStartedTime, - fromBreakoutPanel, - displayAlerts, - isBreakout, - } = props; - - const intl = useIntl(); - const [timeSync] = useTimeSync(); - const timeRemainingInterval = React.useRef>(); - const [remainingTime, setRemainingTime] = useState(-1); - - const currentDate: Date = new Date(); - const adjustedCurrent: Date = new Date(currentDate.getTime() + timeSync); - - const calculateRemainingTime = () => { - const durationInMilliseconds = durationInSeconds * 1000; - const adjustedCurrentTime = adjustedCurrent.getTime(); - - return Math.floor(((referenceStartedTime + durationInMilliseconds) - adjustedCurrentTime) / 1000); - }; - - useEffect(() => { - if (remainingTime && durationInSeconds) { - if (durationInSeconds > 0 && timeRemainingInterval && referenceStartedTime) { - setRemainingTime(calculateRemainingTime()); - } - - clearInterval(timeRemainingInterval.current); - const remainingMillisecondsDiff = ( - (referenceStartedTime + (durationInSeconds * 60000)) - adjustedCurrent.getTime() - ) % 1000; - timeRemainingInterval.current = setInterval(() => { - setRemainingTime((currentTime) => currentTime - 1); - }, remainingMillisecondsDiff === 0 ? 1000 : remainingMillisecondsDiff); - } - - return () => { - clearInterval(timeRemainingInterval.current); - }; - }, [remainingTime, durationInSeconds]); - - const meetingTimeMessage = React.useRef(''); - const boldText = React.useRef(false); - - if (remainingTime >= 0 && timeRemainingInterval) { - if (remainingTime > 0) { - const alertsInSeconds = REMAINING_TIME_ALERT_THRESHOLD_ARRAY.map((item) => item * 60); - - if (alertsInSeconds.includes(remainingTime) && remainingTime !== lastAlertTime && displayAlerts) { - const timeInMinutes = remainingTime / 60; - const message = isBreakoutDuration - ? intlMessages.alertBreakoutEndsUnderMinutes - : intlMessages.alertMeetingEndsUnderMinutes; - const msg = { id: `${message.id}${timeInMinutes === 1 ? 'Singular' : 'Plural'}` }; - const alertMessage = intl.formatMessage(msg, { 0: timeInMinutes }); - - lastAlertTime = remainingTime; - notify(alertMessage, 'info', 'rooms'); - } - - if (isBreakout) { - const breakoutMessage = intl.formatMessage( - intlMessages.breakoutTimeRemaining, - { 0: humanizeSeconds(remainingTime) }, - ); - - return ( - - {breakoutMessage} - - ); - } - - if (fromBreakoutPanel) boldText.current = true; - meetingTimeMessage.current = intl.formatMessage(fromBreakoutPanel || isBreakoutDuration - ? intlMessages.breakoutDuration - : intlMessages.meetingTimeRemaining, { 0: humanizeSeconds(remainingTime) }); - } else { - clearInterval(timeRemainingInterval.current); - setCapturedContentUploading(); - meetingTimeMessage.current = intl.formatMessage(isBreakoutDuration - ? intlMessages.breakoutWillClose - : intlMessages.meetingWillClose); - } - } - - if (boldText.current) { - const words = meetingTimeMessage.current.split(' '); - const time = words.pop(); - const text = words.join(' '); - - return ( - - {text} -
- -
- ); - } - - return ( - - {meetingTimeMessage.current} - - ); -}; - -const MeetingRemainingTimeContainer: React.FC = ({ - fromBreakoutPanel, - isBreakoutDuration, - displayAlerts, -}) => { - const intl = useIntl(); - const loadingRemainingTime = () => { - return ( - - {intl.formatMessage(intlMessages.calculatingBreakoutTimeRemaining)} - - ); - }; - - if (isBreakoutDuration) { - const { - data: breakoutData, - loading: breakoutLoading, - error: breakoutError, - } = useSubscription(FIRST_BREAKOUT_DURATION_DATA_SUBSCRIPTION); - - if (breakoutLoading) return loadingRemainingTime(); - if (!breakoutData) return null; - if (breakoutError) { - logger.error('Error when loading breakout data', breakoutError); - return ( -
- Error: - {JSON.stringify(breakoutError)} -
- ); - } - - const breakoutDuration: number = breakoutData.breakoutRoom[0]?.durationInSeconds; - const breakoutStartedAt: string = breakoutData.breakoutRoom[0]?.startedAt; - const breakoutStartedTime = new Date(breakoutStartedAt).getTime(); - - return ( - - ); - } - - const currentMeeting = useMeeting((m) => { - return { - isBreakout: m.isBreakout, - durationInSeconds: m.durationInSeconds, - createdTime: m.createdTime, - }; - }); - - if (!currentMeeting) return loadingRemainingTime(); - - const { isBreakout } = currentMeeting; - const meetingDurationInSeconds: number = currentMeeting.durationInSeconds ?? 0; - const meetingCreatedTime: number = currentMeeting.createdTime ?? 0; - - return ( - - ); -}; - -export default MeetingRemainingTimeContainer; diff --git a/bigbluebutton-html5/imports/ui/components/common/remaining-time/breakout-duration/component.tsx b/bigbluebutton-html5/imports/ui/components/common/remaining-time/breakout-duration/component.tsx new file mode 100644 index 0000000000..592abe6561 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/common/remaining-time/breakout-duration/component.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import RemainingTime from '/imports/ui/components/common/remaining-time/component'; +import { defineMessages, useIntl } from 'react-intl'; +import { useSubscription } from '@apollo/client'; +import { FIRST_BREAKOUT_DURATION_DATA_SUBSCRIPTION, breakoutDataResponse } from './queries'; +import logger from '/imports/startup/client/logger'; + +const intlMessages = defineMessages({ + calculatingBreakoutTimeRemaining: { + id: 'app.calculatingBreakoutTimeRemaining', + description: 'Message that tells that the remaining time is being calculated', + }, + breakoutDuration: { + id: 'app.createBreakoutRoom.duration', + description: 'breakout duration time', + }, +}); + +interface BreakoutRemainingTimeContainerProps { + boldText: boolean; +} + +const BreakoutRemainingTimeContainer: React.FC = ({ + boldText, +}) => { + const intl = useIntl(); + const loadingRemainingTime = () => { + return ( + + {intl.formatMessage(intlMessages.calculatingBreakoutTimeRemaining)} + + ); + }; + + const { + data: breakoutData, + loading: breakoutLoading, + error: breakoutError, + } = useSubscription(FIRST_BREAKOUT_DURATION_DATA_SUBSCRIPTION); + + if (breakoutLoading) return loadingRemainingTime(); + if (!breakoutData) return null; + if (breakoutError) { + logger.error('Error when loading breakout data', breakoutError); + return ( +
+ Error: + {JSON.stringify(breakoutError)} +
+ ); + } + + const breakoutDuration: number = breakoutData.breakoutRoom[0]?.durationInSeconds; + const breakoutStartedAt: string = breakoutData.breakoutRoom[0]?.startedAt; + const breakoutStartedTime = new Date(breakoutStartedAt).getTime(); + + const durationLabel = intlMessages.breakoutDuration; + + return ( + + ); +}; + +export default BreakoutRemainingTimeContainer; diff --git a/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/queries.ts b/bigbluebutton-html5/imports/ui/components/common/remaining-time/breakout-duration/queries.ts similarity index 100% rename from bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/queries.ts rename to bigbluebutton-html5/imports/ui/components/common/remaining-time/breakout-duration/queries.ts diff --git a/bigbluebutton-html5/imports/ui/components/common/remaining-time/component.tsx b/bigbluebutton-html5/imports/ui/components/common/remaining-time/component.tsx new file mode 100644 index 0000000000..826411662c --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/common/remaining-time/component.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; +import humanizeSeconds from '/imports/utils/humanizeSeconds'; +import { setCapturedContentUploading } from './service'; +import { Text, Time } from './styles'; +import { notify } from '/imports/ui/services/notification'; +import { Meteor } from 'meteor/meteor'; +import useTimeSync from '/imports/ui/core/local-states/useTimeSync'; + +type intlMsg = { + id: string; + description?: string; +}; + +interface RemainingTimeProps { + referenceStartedTime: number; + durationInSeconds: number; + durationLabel: intlMsg; + isBreakout: boolean; + boldText: boolean; + endingLabel?: intlMsg; + alertLabel?: intlMsg; +} + +const defaultProps = { + endingLabel: undefined, + alertLabel: undefined, +}; + +const METEOR_SETTINGS_APP = Meteor.settings.public.app; +const REMAINING_TIME_ALERT_THRESHOLD_ARRAY: [number] = METEOR_SETTINGS_APP.remainingTimeAlertThresholdArray; + +let lastAlertTime: number | null = null; + +const RemainingTime: React.FC = (props) => { + const { + referenceStartedTime, + durationInSeconds, + durationLabel, + endingLabel, + alertLabel, + isBreakout, + boldText, + } = props; + + const intl = useIntl(); + const [timeSync] = useTimeSync(); + const timeRemainingInterval = React.useRef>(); + const [remainingTime, setRemainingTime] = useState(-1); + + const currentDate: Date = new Date(); + const adjustedCurrent: Date = new Date(currentDate.getTime() + timeSync); + + const calculateRemainingTime = () => { + const durationInMilliseconds = durationInSeconds * 1000; + const adjustedCurrentTime = adjustedCurrent.getTime(); + + return Math.floor(((referenceStartedTime + durationInMilliseconds) - adjustedCurrentTime) / 1000); + }; + + useEffect(() => { + if (remainingTime && durationInSeconds) { + if (durationInSeconds > 0 && timeRemainingInterval && referenceStartedTime) { + setRemainingTime(calculateRemainingTime()); + } + + clearInterval(timeRemainingInterval.current); + const remainingMillisecondsDiff = ( + (referenceStartedTime + (durationInSeconds * 60000)) - adjustedCurrent.getTime() + ) % 1000; + timeRemainingInterval.current = setInterval(() => { + setRemainingTime((currentTime) => currentTime - 1); + }, remainingMillisecondsDiff === 0 ? 1000 : remainingMillisecondsDiff); + } + + return () => { + clearInterval(timeRemainingInterval.current); + }; + }, [remainingTime, durationInSeconds]); + + const meetingTimeMessage = React.useRef(''); + + if (remainingTime >= 0 && timeRemainingInterval) { + if (remainingTime > 0) { + const alertsInSeconds = REMAINING_TIME_ALERT_THRESHOLD_ARRAY.map((item) => item * 60); + + if (alertsInSeconds.includes(remainingTime) && remainingTime !== lastAlertTime && alertLabel) { + const timeInMinutes = remainingTime / 60; + const msg = { id: `${alertLabel.id}${timeInMinutes === 1 ? 'Singular' : 'Plural'}` }; + const alertMessage = intl.formatMessage(msg, { 0: timeInMinutes }); + + lastAlertTime = remainingTime; + notify(alertMessage, 'info', 'rooms'); + } + + meetingTimeMessage.current = intl.formatMessage(durationLabel, { 0: humanizeSeconds(remainingTime) }); + if (isBreakout) { + return ( + + {meetingTimeMessage.current} + + ); + } + } else { + clearInterval(timeRemainingInterval.current); + setCapturedContentUploading(); + if (endingLabel) meetingTimeMessage.current = intl.formatMessage(endingLabel); + } + } + + if (boldText) { + const words = meetingTimeMessage.current.split(' '); + const time = words.pop(); + const text = words.join(' '); + + return ( + + {text} +
+ +
+ ); + } + + return ( + + {meetingTimeMessage.current} + + ); +}; + +RemainingTime.defaultProps = defaultProps; + +export default RemainingTime; diff --git a/bigbluebutton-html5/imports/ui/components/common/remaining-time/meeting-duration/component.tsx b/bigbluebutton-html5/imports/ui/components/common/remaining-time/meeting-duration/component.tsx new file mode 100644 index 0000000000..5d051c7709 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/common/remaining-time/meeting-duration/component.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import RemainingTime from '/imports/ui/components/common/remaining-time/component'; +import useMeeting from '/imports/ui/core/hooks/useMeeting'; +import { defineMessages, useIntl } from 'react-intl'; + +const intlMessages = defineMessages({ + calculatingBreakoutTimeRemaining: { + id: 'app.calculatingBreakoutTimeRemaining', + description: 'Message that tells that the remaining time is being calculated', + }, + meetingTimeRemaining: { + id: 'app.meeting.meetingTimeRemaining', + description: 'Message that tells how much time is remaining for the meeting', + }, + meetingWillClose: { + id: 'app.meeting.meetingTimeHasEnded', + description: 'Message that tells time has ended and meeting will close', + }, + breakoutTimeRemaining: { + id: 'app.breakoutTimeRemainingMessage', + description: 'Message that tells how much time is remaining for the breakout room', + }, + breakoutWillClose: { + id: 'app.breakoutWillCloseMessage', + description: 'Message that tells time has ended and breakout will close', + }, + alertBreakoutEndsUnderMinutes: { + id: 'app.meeting.alertBreakoutEndsUnderMinutes', + description: 'Alert that tells that the breakout ends under x minutes', + }, + alertMeetingEndsUnderMinutes: { + id: 'app.meeting.alertMeetingEndsUnderMinutes', + description: 'Alert that tells that the meeting ends under x minutes', + }, +}); + +const MeetingRemainingTimeContainer: React.FC = () => { + const intl = useIntl(); + const loadingRemainingTime = () => { + return ( + + {intl.formatMessage(intlMessages.calculatingBreakoutTimeRemaining)} + + ); + }; + const currentMeeting = useMeeting((m) => { + return { + isBreakout: m.isBreakout, + durationInSeconds: m.durationInSeconds, + createdTime: m.createdTime, + }; + }); + + if (!currentMeeting) return loadingRemainingTime(); + + const meetingDurationInSeconds: number = currentMeeting.durationInSeconds ?? 0; + const meetingCreatedTime: number = currentMeeting.createdTime ?? 0; + const { isBreakout } = currentMeeting; + + const durationLabel = isBreakout + ? intlMessages.breakoutTimeRemaining + : intlMessages.meetingTimeRemaining; + + const endingLabel = isBreakout + ? intlMessages.breakoutWillClose + : intlMessages.meetingWillClose; + + const alertLabel = isBreakout + ? intlMessages.alertBreakoutEndsUnderMinutes + : intlMessages.alertMeetingEndsUnderMinutes; + + return ( + + ); +}; + +export default MeetingRemainingTimeContainer; diff --git a/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/service.ts b/bigbluebutton-html5/imports/ui/components/common/remaining-time/service.ts similarity index 100% rename from bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/service.ts rename to bigbluebutton-html5/imports/ui/components/common/remaining-time/service.ts diff --git a/bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/styles.ts b/bigbluebutton-html5/imports/ui/components/common/remaining-time/styles.ts similarity index 100% rename from bigbluebutton-html5/imports/ui/components/common/meeting-remaining-time-graphql/styles.ts rename to bigbluebutton-html5/imports/ui/components/common/remaining-time/styles.ts diff --git a/bigbluebutton-html5/imports/ui/components/notifications-bar/container.jsx b/bigbluebutton-html5/imports/ui/components/notifications-bar/container.jsx index 72f59b0141..b8d3b2135e 100644 --- a/bigbluebutton-html5/imports/ui/components/notifications-bar/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/notifications-bar/container.jsx @@ -5,7 +5,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import Auth from '/imports/ui/services/auth'; import Meetings, { MeetingTimeRemaining } from '/imports/api/meetings'; import { isEmpty } from 'radash'; -import MeetingRemainingTime from '/imports/ui/components/common/meeting-remaining-time-graphql/component'; +import MeetingRemainingTime from '/imports/ui/components/common/remaining-time/meeting-duration/component'; import Styled from './styles'; import { layoutSelectInput, layoutDispatch } from '../layout/context'; import { ACTIONS } from '../layout/enums'; @@ -159,9 +159,7 @@ export default injectIntl(withTracker(({ intl }) => { if (currentBreakout) { data.message = ( - + ); } } @@ -177,9 +175,7 @@ export default injectIntl(withTracker(({ intl }) => { if (underThirtyMin && !isBreakout) { data.message = ( - + ); } } diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/breakout-room/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/breakout-room/component.jsx index cba41e5d5b..1c2203c63f 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/breakout-room/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/breakout-room/component.jsx @@ -4,7 +4,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import Icon from '/imports/ui/components/common/icon/component'; import Styled from './styles'; import { ACTIONS, PANELS } from '../../../layout/enums'; -import MeetingRemainingTime from '/imports/ui/components/common/meeting-remaining-time-graphql/component'; +import BreakoutRemainingTime from '/imports/ui/components/common/remaining-time/breakout-duration/component'; const intlMessages = defineMessages({ breakoutTitle: { @@ -61,9 +61,7 @@ const BreakoutRoomItem = ({ {intl.formatMessage(intlMessages.breakoutTitle)} - + From 57e5dcc54a18e35b480e9f57e7a2127e671dfd1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Wed, 24 Jan 2024 13:53:01 -0300 Subject: [PATCH 0237/1039] fix: remove unnecessary condition --- .../ui/components/whiteboard/container.jsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx index 088a0fe71e..9f11ce11a9 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/container.jsx @@ -96,7 +96,7 @@ const WhiteboardContainer = (props) => { const { data: cursorData } = useSubscription(CURSOR_SUBSCRIPTION); const { pres_page_cursor: cursorArray } = (cursorData || []); - const { data: annotationStreamData, loading: annotationStreamLoading } = useSubscription( + const { data: annotationStreamData } = useSubscription( CURRENT_PAGE_ANNOTATIONS_STREAM, { variables: { lastUpdatedAt: new Date(0).toISOString() }, @@ -126,12 +126,16 @@ const WhiteboardContainer = (props) => { let shapes = {}; let bgShape = []; - if (!annotationStreamLoading) { - const pageAnnotations = annotations - .filter((annotation) => annotation.pageId === currentPresentationPage?.pageId); + const pageAnnotations = annotations + .filter((annotation) => annotation.pageId === currentPresentationPage?.pageId); - shapes = formatAnnotations(pageAnnotations, intl, curPageId, pollResults, currentPresentationPage); - } + shapes = formatAnnotations( + pageAnnotations, + intl, + curPageId, + pollResults, + currentPresentationPage, + ); const { isIphone } = deviceInfo; From 575c5149c30cb092735d4d3435e42bace56321ec Mon Sep 17 00:00:00 2001 From: Tainan Felipe Date: Wed, 24 Jan 2024 14:42:38 -0300 Subject: [PATCH 0238/1039] Fix: Remove Ts/lint errors --- .../poll/poll-graphql/component.tsx | 77 ++++--------------- .../poll-graphql/components/LiveResult.tsx | 32 +++----- ...lquestionArea.tsx => PollQuestionArea.tsx} | 6 +- .../poll-graphql/components/ResponseArea.tsx | 1 + .../components/StartPollButton.tsx | 52 +++++++------ .../components/poll/poll-graphql/queries.ts | 28 ++++++- 6 files changed, 84 insertions(+), 112 deletions(-) rename bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/{PollquestionArea.tsx => PollQuestionArea.tsx} (96%) diff --git a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/component.tsx index 05f63ebc1a..3f966c2491 100644 --- a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/component.tsx @@ -1,38 +1,29 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { Session } from 'meteor/session'; import { Meteor } from 'meteor/meteor'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; import Header from '/imports/ui/components/common/control-header/component'; +import { useMutation, useSubscription } from '@apollo/client'; import { Input } from '../../layout/layoutTypes'; import { layoutDispatch, layoutSelectInput } from '../../layout/context'; import { addNewAlert } from '../../screenreader-alert/service'; import { PANELS, ACTIONS } from '../../layout/enums'; import useMeeting from '/imports/ui/core/hooks/useMeeting'; -import { useMutation, useSubscription } from '@apollo/client'; -import { POLL_CANCEL, POLL_CREATE, POLL_PUBLISH_RESULT } from './mutation'; +import { POLL_CANCEL } from './mutation'; import { GetHasCurrentPresentationResponse, getHasCurrentPresentation } from './queries'; import EmptySlideArea from './components/EmptySlideArea'; -import { getSplittedQuestionAndOptions, isDefaultPoll, pollTypes, removeEmptyLineSpaces, validateInput } from './service'; +import { getSplittedQuestionAndOptions, pollTypes, validateInput } from './service'; import Toggle from '/imports/ui/components/common/switch/component'; -import DraggableTextArea from '/imports/ui/components/poll/dragAndDrop/component'; import Styled from '../styles'; -import Checkbox from '/imports/ui/components/common/checkbox/component'; -import StartPollButton from './components/StartPollButton'; import ResponseChoices from './components/ResponseChoices'; import ResponseTypes from './components/ResponseTypes'; -import PollquestionArea from './components/PollquestionArea'; +import PollQuestionArea from './components/PollQuestionArea'; import LiveResultContainer from './components/LiveResult'; -const CHAT_CONFIG = Meteor.settings.public.chat; -const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id; - const POLL_SETTINGS = Meteor.settings.public.poll; const ALLOW_CUSTOM_INPUT = POLL_SETTINGS.allowCustomResponseInput; const MAX_CUSTOM_FIELDS = POLL_SETTINGS.maxCustom; -const MAX_INPUT_CHARS = POLL_SETTINGS.maxTypedAnswerLength; -const MIN_OPTIONS_LENGTH = 2; -const QUESTION_MAX_INPUT_CHARS = 1200; const intlMessages = defineMessages({ pollPaneTitle: { @@ -251,14 +242,11 @@ const PollCreationPanel: React.FC = ({ hasPoll, hasCurrentPresentation, }) => { - const [pollPublishResult] = useMutation(POLL_PUBLISH_RESULT); const [stopPoll] = useMutation(POLL_CANCEL); - const [createPoll] = useMutation(POLL_CREATE); const intl = useIntl(); const textareaRef = useRef(null); const [customInput, setCustomInput] = React.useState(false); - const [isPolling, setIsPolling] = useState(false); const [question, setQuestion] = useState(''); const [questionAndOptions, setQuestionAndOptions] = useState(''); const [optList, setOptList] = useState>([]); @@ -269,14 +257,6 @@ const PollCreationPanel: React.FC = ({ const [isPasting, setIsPasting] = useState(false); const [type, setType] = useState(''); - const handleBackClick = () => { - setIsPolling(false); - setError(null); - stopPoll(); - Session.set('resetPollPanel', false); - document?.activeElement?.blur(); - }; - const handleInputChange = ( e: React.ChangeEvent, index: number, @@ -313,11 +293,12 @@ const PollCreationPanel: React.FC = ({ const clearWarning = maxOptionsWarning && optionsListLength <= MAX_CUSTOM_FIELDS; const clearError = input.length > 0 && type === pollTypes.Response; - if (optionsListLength > MAX_CUSTOM_FIELDS && optList[MAX_CUSTOM_FIELDS] === undefined) { + if (optionsListLength > MAX_CUSTOM_FIELDS && optList[MAX_CUSTOM_FIELDS] === undefined) { setWarning(intl.formatMessage(intlMessages.maxOptionsWarning)); - if (!isPasting) return null; - maxOptionsWarning = intl.formatMessage(intlMessages.maxOptionsWarning); - setIsPasting(false); + if (isPasting) { + maxOptionsWarning = intl.formatMessage(intlMessages.maxOptionsWarning); + setIsPasting(false); + } } setQuestionAndOptions(input); setOptList(optionsList); @@ -338,13 +319,6 @@ const PollCreationPanel: React.FC = ({ } }; - const handlePollValuesText = (text: string) => { - if (text && text.length > 0) { - const validatedInput = validateInput(text); - setQuestionAndOptionsFn(validatedInput); - } - }; - const handleRemoveOption = (index: number) => { const list = [...optList]; const removed = list[index]; @@ -391,29 +365,6 @@ const PollCreationPanel: React.FC = ({ } }; - const handleAutoOptionToogle = () => { - const toggledValue = !customInput; - - if (customInput === true && toggledValue === false) { - const questionAndOptionsList = removeEmptyLineSpaces(questionAndOptions as string); - setQuestion(questionAndOptionsList.join('\n')); - setCustomInput(toggledValue); - setOptList([]); - setType(null); - } else { - const inputList = removeEmptyLineSpaces(question as string); - const { splittedQuestion, optionsList } = getSplittedQuestionAndOptions(inputList); - const clearWarning = optionsList.length > MAX_CUSTOM_FIELDS - ? intl.formatMessage(intlMessages.maxOptionsWarning) : null; - handlePollLetterOptions(); - setQuestionAndOptions(inputList.join('\n')); - setOptList(optionsList); - setCustomInput(toggledValue); - setQuestion(splittedQuestion); - setWarning(clearWarning); - } - }; - const toggleIsMultipleResponse = () => { setIsMultipleResponse((prev) => !prev); return !isMultipleResponse; @@ -452,6 +403,7 @@ const PollCreationPanel: React.FC = ({ : intl.formatMessage(intlMessages.off)} setCustomInput(!customInput)} @@ -469,7 +421,7 @@ const PollCreationPanel: React.FC = ({ {intl.formatMessage(intlMessages.customInputInstructionsLabel)} )} - = ({ secretPoll={secretPoll} question={question} setError={setError} - setIsPolling={setIsPolling} + setIsPolling={() => {}} hasCurrentPresentation={hasCurrentPresentation} handleToggle={handleToggle} error={error} @@ -513,6 +465,7 @@ const PollCreationPanel: React.FC = ({ return (
= ({ Session.set('pollInitiated', false); }, }} + customRightButton={null} /> {pollOptions()} {intl.formatMessage(intlMessages.showRespDesc)} @@ -581,7 +535,6 @@ const PollCreationPanelContainer: React.FC = () => { const { data: getHasCurrentPresentationData, loading: getHasCurrentPresentationLoading, - error: getHasCurrentPresentationError, } = useSubscription(getHasCurrentPresentation); if (currentUserLoading || !currentUser) return null; diff --git a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/LiveResult.tsx b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/LiveResult.tsx index 6783a03403..ff6a86c541 100644 --- a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/LiveResult.tsx +++ b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/LiveResult.tsx @@ -1,14 +1,14 @@ import { useMutation, useSubscription } from '@apollo/client'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { Session } from 'meteor/session'; import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, } from 'recharts'; import Styled from '../styles'; import { ResponseInfo, UserInfo, getCurrentPollData, getCurrentPollDataResponse } from '../queries'; import logger from '/imports/startup/client/logger'; -import { defineMessages, useIntl } from 'react-intl'; import Settings from '/imports/ui/services/settings'; -import { Session } from 'meteor/session'; import { POLL_CANCEL, POLL_PUBLISH_RESULT } from '../mutation'; const intlMessages = defineMessages({ @@ -51,11 +51,8 @@ const intlMessages = defineMessages({ }); interface LiveResultProps { - multipleResponses: boolean; questionText: string; responses: Array; - secret: boolean; - published: boolean; isSecret: boolean; usersCount: number; numberOfAnswerCount: number; @@ -65,11 +62,8 @@ interface LiveResultProps { } const LiveResult: React.FC = ({ - multipleResponses, questionText, responses, - secret, - published, isSecret, usersCount, numberOfAnswerCount, @@ -80,7 +74,6 @@ const LiveResult: React.FC = ({ const intl = useIntl(); const [pollPublishResult] = useMutation(POLL_PUBLISH_RESULT); const [stopPoll] = useMutation(POLL_CANCEL); - const [waitingResponsesCount, setWaitingResponsesCount] = React.useState(0); const publishPoll = useCallback((pId: string) => { pollPublishResult({ @@ -90,10 +83,6 @@ const LiveResult: React.FC = ({ }); }, []); - useEffect(() => { - setWaitingResponsesCount(usersCount); - }, []); - return (
@@ -102,17 +91,17 @@ const LiveResult: React.FC = ({ {questionText ? {questionText} : null} - {waitingResponsesCount !== numberOfAnswerCount + {usersCount !== numberOfAnswerCount ? ( {`${intl.formatMessage(intlMessages.waitingLabel, { 0: numberOfAnswerCount, - 1: waitingResponsesCount, + 1: usersCount, })} `} ) : {intl.formatMessage(intlMessages.doneLabel)}} - {waitingResponsesCount !== numberOfAnswerCount + {usersCount !== numberOfAnswerCount ? : null} @@ -225,8 +214,9 @@ const LiveResultContainer: React.FC = () => { users, } = currentPoll; - const usersCount = 10; - const numberOfAnswerCount = responses[0].pollResponsesCount; + const numberOfAnswerCount = currentPoll.responses_aggregate.aggregate.sum.optionResponsesCount; + const numberOfUsersCount = currentPoll.users_aggregate.aggregate.count; + return ( { secret={secret} published={published} isSecret={isSecret} - usersCount={usersCount} - numberOfAnswerCount={numberOfAnswerCount} + usersCount={numberOfUsersCount} + numberOfAnswerCount={numberOfAnswerCount ?? 0} animations={animations} pollId={pollId} users={users} diff --git a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/PollquestionArea.tsx b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/PollQuestionArea.tsx similarity index 96% rename from bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/PollquestionArea.tsx rename to bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/PollQuestionArea.tsx index 424d101961..654afc3f87 100644 --- a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/PollquestionArea.tsx +++ b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/PollQuestionArea.tsx @@ -25,7 +25,7 @@ const intlMessages = defineMessages({ }, }); -interface PollquestionAreaProps { +interface PollQuestionAreaProps { customInput: boolean; optList: Array<{ val: string }>; warning: string | null; @@ -39,7 +39,7 @@ interface PollquestionAreaProps { question: string | string[]; } -const PollquestionArea: React.FC = ({ +const PollQuestionArea: React.FC = ({ customInput, optList, warning, @@ -101,4 +101,4 @@ const PollquestionArea: React.FC = ({ ); }; -export default PollquestionArea; +export default PollQuestionArea; diff --git a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/ResponseArea.tsx b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/ResponseArea.tsx index 9d3c366591..4be4ef9cd7 100644 --- a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/ResponseArea.tsx +++ b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/ResponseArea.tsx @@ -126,6 +126,7 @@ const ResponseArea: React.FC = ({ : intl.formatMessage(intlMessages.off)} handleToggle()} diff --git a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/StartPollButton.tsx b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/StartPollButton.tsx index c0a154ba3f..83dd7c170a 100644 --- a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/StartPollButton.tsx +++ b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/StartPollButton.tsx @@ -113,32 +113,34 @@ const StartPollButton: React.FC = ({ err = intl.formatMessage(intlMessages.optionErr); } - if (err) return setError(err); - - setIsPolling(true); - const verifiedPollType = checkPollType( - type, - optionsList, - intl.formatMessage(intlMessages.yes), - intl.formatMessage(intlMessages.no), - intl.formatMessage(intlMessages.abstention), - intl.formatMessage(intlMessages.true), - intl.formatMessage(intlMessages.false), - ); - const verifiedOptions = optionsList.map((o) => { - if (o.val.trim().length > 0) return o.val; - return null; - }); - if (verifiedPollType === pollTypes.Custom) { - startPoll( - verifiedPollType, - secretPoll, - question, - isMultipleResponse, - verifiedOptions?.filter(Boolean), - ); + if (err) { + setError(err); } else { - startPoll(verifiedPollType, secretPoll, question, isMultipleResponse); + setIsPolling(true); + const verifiedPollType = checkPollType( + type, + optionsList, + intl.formatMessage(intlMessages.yes), + intl.formatMessage(intlMessages.no), + intl.formatMessage(intlMessages.abstention), + intl.formatMessage(intlMessages.true), + intl.formatMessage(intlMessages.false), + ); + const verifiedOptions = optionsList.map((o) => { + if (o.val.trim().length > 0) return o.val; + return null; + }); + if (verifiedPollType === pollTypes.Custom) { + startPoll( + verifiedPollType, + secretPoll, + question, + isMultipleResponse, + verifiedOptions?.filter(Boolean), + ); + } else { + startPoll(verifiedPollType, secretPoll, question, isMultipleResponse); + } } }} /> diff --git a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/queries.ts b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/queries.ts index bbc84040bd..a6d05a8a27 100644 --- a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/queries.ts +++ b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/queries.ts @@ -31,6 +31,19 @@ export interface PollInfo { multipleResponses: boolean; users: Array; responses: Array; + users_aggregate: { + aggregate: { + count: number; + }; + }; + responses_aggregate: { + aggregate: { + count: number; + sum: { + optionResponsesCount: number; + } + }; + }; } export interface getCurrentPollDataResponse { @@ -49,7 +62,7 @@ export const getHasCurrentPresentation = gql` export const getCurrentPollData = gql` subscription getCurrentPollData { - poll(limit: 1) { + poll(order_by: {createdAt: desc}, limit: 1,) { pollId published secret @@ -68,6 +81,19 @@ subscription getCurrentPollData { optionDesc pollResponsesCount } + users_aggregate { + aggregate { + count + } + } + responses_aggregate { + aggregate { + count + sum { + optionResponsesCount + } + } + } } } `; From 9b2b52804193abadf5edad28fd61ae3c0f06cd2d Mon Sep 17 00:00:00 2001 From: Gabriel Porfirio Date: Wed, 24 Jan 2024 14:49:03 -0300 Subject: [PATCH 0239/1039] fixing 2 options test and 1 poll test --- .../playwright/core/elements.js | 2 +- .../playwright/options/options.js | 5 ++++- ...oderator-page-dark-mode-Chromium-linux.png | Bin 117482 -> 185090 bytes ...oderator-page-font-size-Chromium-linux.png | Bin 125651 -> 195919 bytes .../playwright/polling/poll.js | 2 ++ 5 files changed, 7 insertions(+), 2 deletions(-) diff --git a/bigbluebutton-tests/playwright/core/elements.js b/bigbluebutton-tests/playwright/core/elements.js index c1c83f333f..b35191f428 100644 --- a/bigbluebutton-tests/playwright/core/elements.js +++ b/bigbluebutton-tests/playwright/core/elements.js @@ -468,7 +468,7 @@ exports.wbStickyNoteShape = 'button[data-testid="tools.note"]'; exports.wbTextShape = 'button[data-testid="tools.text"]'; exports.wbTypedText = 'div[data-shape="text"]'; exports.wbTypedStickyNote = 'div[data-shape="sticky"]'; -exports.wbDrawnRectangle = 'div[data-shape="rectangle"]'; +exports.wbDrawnRectangle = 'div[data-shape-type="geo"]'; exports.wbDrawnLine = 'div[data-shape="draw"]'; exports.multiUsersWhiteboardOn = 'button[data-test="turnMultiUsersWhiteboardOn"]'; exports.multiUsersWhiteboardOff = 'button[data-test="turnMultiUsersWhiteboardOff"]'; diff --git a/bigbluebutton-tests/playwright/options/options.js b/bigbluebutton-tests/playwright/options/options.js index 3bb487173b..88ee53ac9d 100644 --- a/bigbluebutton-tests/playwright/options/options.js +++ b/bigbluebutton-tests/playwright/options/options.js @@ -65,12 +65,13 @@ class Options extends MultiUsers { await this.modPage.waitAndClick(e.modalConfirmButton); const modPageLocator = this.modPage.getLocator('body'); + await this.modPage.page.setViewportSize({ width: 1924, height: 1080 }); const screenshotOptions = { maxDiffPixels: 1000, }; await this.modPage.closeAllToastNotifications(); - + await expect(modPageLocator).toHaveScreenshot('moderator-page-dark-mode.png', screenshotOptions); await openSettings(this.modPage); @@ -85,6 +86,8 @@ class Options extends MultiUsers { await this.modPage.waitAndClick(e.modalConfirmButton); const modPageLocator = this.modPage.getLocator('body'); + + await this.modPage.page.setViewportSize({ width: 1924, height: 1080 }); const screenshotOptions = { maxDiffPixels: 1000, }; diff --git a/bigbluebutton-tests/playwright/options/options.spec.js-snapshots/moderator-page-dark-mode-Chromium-linux.png b/bigbluebutton-tests/playwright/options/options.spec.js-snapshots/moderator-page-dark-mode-Chromium-linux.png index cb8ce1a93f3eef5da1745bd73c5d08dd9d81f06e..9c710ca2deec72d40de3ec354446fb3f2f3b49a6 100644 GIT binary patch literal 185090 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 VAMgydPnsUW%3m{>&6YB(6Aq=*5XYTqyj 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%0DeA0zXxq#df-%=J>G0Y}; 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

-P9AUrI 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%0D6}Cn!3ZqwkyEkv>0;6?=+xK zLf||9ii?>l-rIeo*qT80K6@?@afSEbho}GjwIr(jzRSH*2@pk#E~#KdGcOUM0r z8^NBPT`vmyAB3|NE1q?({A(mpXk0<_;NXMI5sTeUJx#3r&KjqO0DEA{27pEg3EEnv zEg@D7KsN#a(&^x+UkO%Z)~=4d$MOOY4ZsVrjxExyQhSGfzY}YjatM$9nF@xvhE5>o z260mfPL@xs1`#4@y{oPxl9Z#1v$l@tJ$8E^IYE}nxw8c}!z#EG<^gPSHs`~d7-qop z`QxH1aUVg}lvm!wo8#}JZ}%JT^5mv3Jdn~TJ5Y`LW( z#FKirHt$p05Z+O9G}0Zymp>Q#P6VwipziL0oFP9vPT6L>>%qdZ`MjnA?Zp^8zws#s z$0IbU;Z;QZbBH4s)ZyvI3U_=tU+lzqiq^5b=}p3aF!hr{i_=q&mu}9WVliS)_eAf5 z@!A(__bcT1AL^Y43`;iVU_P${vv7(H@nrI!_23Cw-V=9|`6cW+L+ri6V1OF_dH5gX zvs;0Mo+^Vpj&b)i5G`XC@nd1$g_ zxhh0cWpL9sTdrDi4?8(C*PAr_e=?gf?M9CDVcYL>TQXJmF8b>(-V=gd@^l%*;Sc}& zHk4TrfiF{ zo+$BR#(RA>N9(t7x^V$br2K|z1wDSfnii#I&yF@(VpT^xuRAimwY+F;-Gy1$AE0iN zg%BEVC$aeMdp0^R4C8n*?RPc(CYnyF8(>k|;DPLuO~DD8Dh+Docprv)J3*3)xz9+t zb<9FpOOMN5GM3sKOGlADTDAE-LBg~U8CJ?pEq6u=!vM1g*K!vDD_q0j$HqrZ%5fNi z)xKnoH$v(cvYjVR1XWt~ZdqA|pCA7araP zdV4Nfm>a%kaZgR8(*AS7(f&lgX)d{yP4I-Z(ot3I`D5;c66T+ffqAxz`@fC%CZ5n5 z$igi&8ILj|8g$f@2fc}^95%Z24k`+MqFzl;PstBX$_!;@M0Ve8U;PTcR(%Pq^UF!2@h!G7rqm|AuLKU7$Ed7aN`j-^klS~F8PVcjrs zT|II0c9%aDrD(*iLX0>`78<$? zv2C;y0zmGbZqjA7B>hr1)DxU9zix{f0w=_Cg8q8YZFCiT`Y z<>`sb%?4r1@wFf@-k7=?k~o5G(1y;s^ASKUf)^JJVXy`{RmIQhMVfEgq{0uy1g>4# zAH=4))1Mx!wYe(4{A0GH!VdMzO3oxCQz<)OGVep@U|XCF%7pg1u(p=Q?Hp~5A*LE> z{08;vJ~sNCKacxt%ag5SJ~gIfFv;&D)wqE+GW3c|;WeW+#a@&_JrL7!Ffu_ko3=9-w;*#XNA zr(R*kGS4~zy)Lcp@Zpn|SZ=**0|WM*4e367OmGlMRF?7Yl+eCK9~)fzll^R8Hgn+# z$IkJV?&bM=6&|NAjUmBLU$wQ*_y^y=diG1{;$nGA*W*V{357n#>B-rFhlj_co*?qki~`8;zb1DzA8ELwd*GU z!uu8&xKTf3!tJ=+9{hF(|L$Ez#~V02*m5h2XZkfTN9RTirQfcY*WAYz39P2K^uawl zY2{432kxuYZ8AsJ>VtDM<#p9GLoSR%b#1{Ff4AEYp35UX_B2?u{B63v2vrit#BcLi zeJqCfieolk0C|+kA8y%q%Ko6oogM3l(4`fi4{9`9B)ab*(nQ?X5GUZ3hB`XFmnO5F zIDoMVotzkaFt$0xcPUu;w0cM&ay(7mGCO{VY0B3qT%I(5nqFhB5b&lnkdTwsDg>On zY^&zSX;Bl9v9A`@?=`Z!(uunOtZOxi`3x_BE>b|dl9~e7$n3zO zBL|Y_nF0!|@xK~fo$7vTt}9e~k648fNbXf`IRSHlNeaR?looSyU3f6w{;pra#elPt&c zJhptzNVeq-add#5Ul)7mY9`!>+$9iCY4X@)`=XWS*6k1n#1nuPe|y!0{t7-f8(~9B z!}%0`p~Yiv#7WL*(1?B_gHI)3^OTs;^C@P|nfkR6`Fq0tFZEr1NO!BExC9Suozb&SXO z;}LxVY2@hGm=N*fw{Xan#YovJDEa^oiku6MF6RL_+8>pTL23y|G8ni1Dcwl&)2C15 zN2}5O$z1+zfkZ0Rc6tw}1)FJXXMV&c_5wHtWT-bvvqwXs?*NElFqk@Uu+o*MQA`Ug zX9?-P@OfZ+99?QjJWWsR?v9kbJV6#7YHR*wyj5dx@^XLRHM9}#7^MNZXTHXxCPb|_8EP3<#`;Varf@s#qOmxm)+3--^&w#F$}R3H_C$a%%DFx*gx~djU9lyp5JcD zFDZ#Cvn%X#$Kg$70O(*64|0Cc9!TCFmoWb1e50nW_7Olx{K4kiDkL2qhH@OXto9^} zyN2AnE@NxCYP}a8v`6CtMkb&I8M{Y6FTu_$zJpB%KLSHwdZ z(B*`KptO#)b!NmN!*IF9h8Xd1qpx=&pMB~cX00#Q@n}=|=VRinnL;Wy|F7}aE0G}I z1P$@_502NSMl!$Y*Syx%rFO5f25bk_U3~oIM*W8VwPbPM#0Kw^5~Fh`uOmdyDcrX! zc%=(ecdQ<~@y`Wt5!FPodDjW@TR`Agv4Mo0{d}UST-?u?n2uC2@BQQBj9e&W6k8XTccE!L@WRQ3iDqe{v*F#$u|Mkxp8FBoiv0_^ z9&UQGiG!roUAf+3o- zw$%rV?})f|2LyscLyZ9@H0Qn7z-u@9t+)|=7_#}Z#jtt4&EGuDJ0eBItpr@*Gga2^ z<0tJM9S0YFw`%I@4!7gE&awS!0ZlB2?g*9l*`NWqY0J6uMy^hAs;94+6fQVT>7TM> zfqL^f8Hav*XD6rzY zgl{6l6cP8t+k7{A*Z0yq>*E^t2ncxGm-@#qB1Gaj?Wh~}n$iGA@n|kD+EWkkF3{^M z0pbM+P-K6Qb>b#lSt*|3mynR4Z$v!+#ic&`n9IrgSd_r+PUSuV@t{ffVvEn=Ru0%W zZXgN(6P<8zGSXM4@PKwuTFJ1c2bJw!zgJ z+|55!sz!ps!wK!fX!<7DbO2aJV7m*8-pplONZ}0zQH9e{_aiKbPIbhFfyb8+adF)m zhx8A{{(N`TD$$j2=(BKzuAf@_s#v$zlXH5lrSmp+np=i>%M>^((>M;8w0s)I4{es! zC37W&nC{_ud!x8;9+H#aZ3g9}jwRI_QJAaXu&~v>>2eBGFN-eBBg%25%lQPM)p)%t z(%E=UFBi-Dd2gZNUy)vtO>{6{-iz*Ow=RagO%mCAS);;WHSsqf zGLmRdA+cR#uQ4<;^94u4*>f6Um*(0Z98YdcA~IFX(zlBy8OBO5W5K(umyR7tMA=T{ zyx|1iJU2IYu3kOA@8z*EGLbJ?%=_f;8pgh#JOZQ-iE8<|aT{p#d^Mc=^YVM(iagxf z?>hem^*y0vQsG9&f7Q@elLi`Mrv^a$gNLxa|HnM$jnzLjlC>%SJw-BTQm* z8}tO0jhQNeGzh)E1a=S3=yGcSK}9V}@$1*GXSVagHnz4D$D?vLC+W+VFY+Fjww+5m$Nmu&@jD9(Mq_>7nErlwZk|KvfftDNs6hxjQ)$h!4!t%;OqB}O z*X$oi(>VewVb_NRjab0azd{9=|0f6CMjnfM2L;Vx36Hye?%fN(laG+p75hjW{i~xw zp|pTpIYILJWyzioGat*8F(61+Je;-;>hU7V#q!>Ha23_czZmgq6+OXsk0&@ z281%GLw1I^yS@@05|pXUc>ZGG7}RR4gdFE6rI}srHK~9*sC1p2R3wiQdaq;gT;c>^MSz8=D1ze~>g;o~ZcwHS2`-$Y` zpN~y%Zg;{CLp;x?AHBc=hQT9o`h;2a>GS8UJ}u-lG~t0c41VW)1APk%w18tUGB&!& zdSNFQJL|jiAXq*^&c-G1n1S56v$>0d>T+|`+J-w}s^s%+=?Ll;zytsL_qA)`2E*S&y87P9iJM|;3*hXJNhy23Ij;C6H)XD_nt%^JLyRd6bg2UVt$1c_^w$QLu(0q? zkduS<_@4J$=YBtF_WN>MtHR@AJdK5z+i!;UD$ zkM`SqPUqdhs~QD@44-2l;~2?Re(`jpCL#>x*@)O?Xx$pO=f z6ET?7m4QQmwSRirWy1YPvz8mz|JKmqn&o5>4Ns0zmjy3i6Nn7T(f@vtpx|Gts|7&) zjgaY@r(M?36!yB|+ugu+*qiT3W(~2GXajNa&{F;P@9`%aW7jA=Cb`Y6slh8ANv|Rh zqpTa1=uSz1k}S{^$u(rSJ|nz$Z)vL3*kyN)0+ix_8OQpKmgG&*8>#XE~f zc`B2vnQsH|LB3B+PTmbboFeiMa6oHt;DRIGJT^uQy40d1L1W~ab1{0MS2vpo>J@-2 zqH)`VKwn>foh!Tb?K|-2>^E)S*N>YDb-Hrn^jmv+g7rk)^~>k=J=u4M3{3l(yL_d8uKQQ1KM#RG-b+a>udho1UZC;TVM9YhFzc%8&O_>cAiOyMn>=v>-~9se za6mF19&V0M3psro&sPU(8#0tL-J?fefCd^UNDBJpQ^|RQ0AF5W4{tOQdth#E4zgJb zjEJ!3;Y(Ut+BL9Oyaf?D7&Q*Mxx&=mL@Ux#6KnYg^kidLwPUj4;mb>gtL@;4`*V zX4F`Y;*vAdkA%MUQO`*W-o&9E;8>PH=DSmEYx5({wRKxub&GYdN}L}J`D*N7Cxc69E~`K|y63%@Hs#>ppavq2N`HtSaK(fC za46fYTes#WDwvdr=oH=D_<^+Y707JkOv009jpq*u{)Ftbd6NL zSt1L+t>aBnr=Tw`E`F1b1NieLA72b8P^0xla8XlJZ;t1arO79J1xfL(FJDv()x&`> z!PuKgCC_QC7Z7tJ4bnu7iDF5W))OBAgy2jsuRk&}B2O-n`tP5r6bP}vp_-bR;RXqY z<#5R}3K~2OM7!Afh^|r$9(nm(9arVWI)lde{zxy1U@%k2_Qq2OtS%&8RXJZ z%aP@npyTo#q9bGyswQ8xWrCieZ*+E+>Qrt8&uw$~%XGQ9Gc-*c%Vm2mp%xB}jE*J- zJH9d`acu&iXW%Bvy~de=Xc4G}A%>HKDd74a+bILpMEefhhp)?>Skaf;^V)Ku{)Jz< z>^NphHx?HiBxALA4Ut3=hb9HWfBshsa2MPSMoUT(%2`%wiTu``00}ubIA|Fgixzg> z;r@*F0BpPxQrNYKvEFf|^9D)6M2h3Dn)s^!RlNs1gL1$ z2b1S{H+vY)fhTr!aA=;LO#*ieYRc~xt8R%Ocwf4@y5yV=PXXg?yf9abm@B;v(h~5O zU}hZ;UA5g>@6yhW9C%&W*x2zx#<<~B0-c7OpesNd#`jXQ(S~B&`X47=X-~fYv7%2P``+afPx-|sDq#0_X){4{j_+@7D1?;BJ5gH zU7h0?O%LO9V-06dDF)(Og}zv}bE64XY!(f({U6UjuTnxC9UW0IF;sXUE>Lq8$@^T| zg4@i3g5=I>*gXGkVRf!*-s1tCie?Mm^)MW3V}IcB*4R^qSX2vw0_#O8ttQQ`8-0PI zhXB}W(b?OVd#IJoMMH?bmA;p5$;mn2%VVUq z)QXU?elDZPA$Z$$?_5>fdpPosOP`rQ@sjNjmE4P>T1+Foo#bF$6D8Of?FyTo8!u7`WTJ!iqc~u%!ArftD?oyI2M_NC_5g@&M_jo%xD zJv)m6vaJ;a;zkLAauvd@DOaE}8%XW}e-!duz5`PES~D%%nTjRUka(v4)dk??f25=+ z0E-;UQ2^T6A2BhKeoYd-7bm75W-T*CnSm>^I377aI1$TXHqhEOp8|x<2b)=_tMR$X zd@|?8i;!>M?odqAMnE&n3|OU{V>h3|A-2{TrUL97)%!7{O;3p*V~9C(cASy3;Y}# zq2Pto?<#=fo|zn0Q@^_ugXjm>3Meq92~n${(D%J!t8Tj)jAJh z*_Aq(AJ^ll=Tug7V<59tw&z#X@b3P8)=}1CC~`*fwc%3`QX3+6icu412j^uja?40H zWmwZm@8=h?p(9w{`o(Wz4kr@OAM6Ovx!F)YK+{l!{x3}fhp9K&>Q+mne^2`nM-!8} zcSso&hmMYvL+cCB7)fP5TFH`Gaf<$SNKdeKHE+SW8s=}vN#1`o5nAr!*gOE`a7Ahp0950=%SJRVr9|w09Kg!`QdtLfgP_#A3nM`f7`Tc z|3q>qh?(79gDRDy%~;Qg{W07F$p-=1^u@`JdqYz6Q2V71KD;}G{`f?BzxaM_ZLQy<-mx~(26Dyd!<1yzy!ntT);9_-!L|{i-+nxo#p?At5N}Br1=zX;JI+u zLOHIF`L0Lhg+d$~;6usG#eUg7tbYl$2Q_oN*Y$l#gwoN|J{d<{uM$NVf6H_Dgk%(S zJulDxtR&&vko1u?+UvaJ*aZJNna$%cGW)0UzV(jGkd$Pa-y2!8?&0)~g-JL(?ZCdtwqpX_H_*}@sRX2LV-LC6c9wK( z6mHW{K$SX13*WEOAEcqbIe2z+?3DrRE1rR%ccw33ym)P=T9>leeZf?27PR3L$UgVK zox7x0M}ch**NIx(5j^)t3$W2ret2~{iY${@iIU|daA6%6UhPEbb{!tssu!v5Sg5oG z%QTSqMWMg`73Nxb)byz^Gb4#GG!S>$FrS70ksO-gwqM+aP|Opo$3{$)Z)*;4@c9;W}&&xnrmen`z@Lt)D6CI_QnN$NsFd+P-7?Ld#@&70)b2Bc-nKB*32alWgzu%-oDC zd7o2%`Snbh%#Qu`adm51&|KYp@)EvGEY=Mm1Tx%B(@&EFfJGBQ37314_~2PaF8 z@b~jk?9pfc9{zm&H0G!gg(;mj75IaoPr^>#Kz&K#F<&1{Nf0S_9`f01csn9E^;eSz zChUysy|SiSRySFs{^~(Q@jH$@@2|nIlMiYbugS=wW0p9^G_yuG8}EAh=+yn+umEWF z^B3oC>+)En{b@uN=3NS4Y+(+NwKII? zs0+@PgzT$eYQ!6XjQi9iB<=RrldccOOHIp87O5qKil5-6_jnVM`Ji~C?kj?76c<|wM=LF)sk59B79$AhUtw_gLFn~V5ONC}45NBxQq zN~#wL$yi)oehW~8^vR;7rLEw&I5*M}@LffL-(|6eCSSK)i-RL+_D)@lKQcw`(2K_No>m&~}tr^8f-{wa~R{BbuJ)!_% z1A>|0h>+GsCsQCBl>s~*GqbFLG>{vVR#n}kFHhV9o&pN173k^6;>waf>vz-Pgta+` zynT0l;d8XRX|$#>(3M0$#zEFdbQ@7vX?v?u`;w^Q+0!=OJ-8RnZ{_`<`X>c%&k1EY zFs(>fUhS;Lua*3dn8Tv$^Rt`rbw7DB$_X~;Cs&Hyt$C{IS#DJ#51h+KhO)PpEF@pp zi_qF5y1$b7)QA^?mI)fw7W+gM9G7tDC7W`!J&*Z`4d}Km3-)6yM+t8c=qU;=uiUX3 zFAn80GBfT1h+rcPg1vaT*Z4x1IiwJZZh>}1M1eZ0Uqj-z!H^MaA@37b;Cp?9J-M;S zzWOIfxU&n`%^6-)SSAa5{scjAV=_M>&J@g&_<9#k)V!gRbHh4GT%MF#ZzzV+p0FW8@<=z5u9D2{0n1Yq)6<-2a0X|vuij#AAd1LcUMq?lwmGs2oW|wQcNPFPOysqV< zYc)%;e_@Dr5r0Lo?NF|?-qf)o8@obY7hc00D=m8VW)~5&U0uVvE7a$w{-y=5hAzMl;Acb87uOFns_i=20qk+t~KY@<` z4^J7Qap#b~$9|!4V4z#4V{p%GmCbIUQEyXRwpR@QwTv7?p+#?L*@>LC?6EB9Xk9{X zYo(r{FmRY8saE*uCh5r;ya9}oWWDh6P)!&3w32mocVtR{;FTHXa(EEG`4 z^z>Me1PnEuy-kYLR%lHT0c zU;uRGuU{k&Xhpk?#k?sCcUD%~s9s3@NdEP$I+AGe-Mizz8!+7lrvPR9Cc@?k3CwHd z-H1L{#HO;a`>y<@9~K&o*5rhWgIZYjT_#V9{nN4z34#4n)9b?o$qV?^<@x%o{Vk;H z9ov#3uq4riYL06f5vlU^fvtae#2|w;-TuWSdaxRevp!kwZcoW))eaa2A?PmkEgE4g zkWKnm#yk-c5&{w|=_Ux+LZDlB6c>z7s9h8E*?b6y!dxJqsvm9i^^U;BiAqf!oSQE9 z1e|$kWlSj%%L8{lFVOhY#F=T$mg%3T|Gm@90pG=qXQy$e=N!RJ&u2kDt5$!1?5P<` zwE*TA+3T;xpYnz+cumYr(+80&BUO4L!Y-SB1}VZ}eBhK+lTsIQRL7V27)o|wYSD^YrloOiD;=T ztDb~>Qznsw`w9pNX&Fe^+Sq*PyYe#&qE8bAaKL-*M90$$QZ^l@)&0+ay*NOjR+^ix zm*FMPv~Zh{STP91(X22f|BL6D@Wn~W}I-Vbz0=`Vpb^E5-ji`hNCwQ?B9anIZ1Z-P@JB`za zXu*Hfc&^|^y6t?|%aR4eCiX`l%U7h9c+lL~bS;(jX=!h7Xdp?z5(tk#6Q^&w4YD9a zn60+W99oG)_2w5prl!8%Tve#kht$3Mb>DCi;HMs%pd%qgvWPe7JXH}WDg*c%sOox@ zdU>!6fRa0R?ksBLJOJhm96J=zFN-@=>*t5{fL72iG=E3zZ{B_hr*^fi+-)edNUvTB zRH=fNsh}q9CvY9WJddB_UG~NV14eB@d zrgyxz$(FWx8X#49J-c?3Gz|}b z#7vdp0D1`Avw`L1JWHQHgVGZk0KyBqZ`}x%!1?Y3%ilcQ%>4IPEi|#xsuXhyKgd%L z-<_{_UTVd;(Rc(b8oqt|c7Okb?IMEG~hY!1K8i{U%@?Q{$YtM*9FWrrOLlvX~?m;xni!V>IpS9@I z7G})ywBNf)?PG;bu4??D6=HF-Tke`F8yYv!ptPZ|cV7P{Q%Y9yuGt6Xyg%zXS_xWa zVw&KNbEtXfLz{H@hboAD(fo`%ixHonpI?gO)!S(&Q2TX)SWt+h6*~ilzCa3Yk;l#q~sc1Mt3p6;2WrRlU@DlN4b0U?m8n?p0O8w2&i<(&kc(3D98 z9n_?``OCjt<+iV{NWi?)%e*CcooeRD?-7gc-rfuiI784NkC3$I%F6!qQR15v@)u)zO#O1RVVe$TcYWh?1Du6NJ7tHe6 zc|PNN8MOrrOqS;$X(I<+T%2B*$@oe?VNL=U^8ft$+`f9$5%kdU;?x`V8VZ^T(#a?P zycRF9`8`5;|LZec{1C%%7uDf|JN|zk6brnkm&g*ET)@t(tbxleFu{XvbCb6Ow~#GJnJY(&GO|-)M|Sf zpUm9CB`8cx8*bZJe+L=vC zgI=Xv1xEbZY8*dx(PnQ~XSK0b7@uFfd(L=|&6Y|Y_B&Vgbf%-b>Eo;TsdMI~5Yr9! zhWp5$>jpu#ni7ON)|PNO$(jG@KKh@ZsvfC`v1s#DudjSf_N&Fv2fxlSd(2pUTxx5j zgfS}T=W^rpkw5!~*^MqUdRZ{7SHdCQ7&hA-63F)R364qCn{)6#|9!((*z2NQo4$3W ziMo`+zMj)yH!Y!8D#;T4l=V<2M&RzRNWG~OQ_BrC*pqE=ujBQkNvCUUp5KWca+g80 zZma0m=c0T0p8TH=yp!HKdRZz>aX80Q0I#T=`wGRfdKH0OLuW9MGL(q;yV+mp-P4PY4^AWhtJUd~xLrLFuGLy|(A*tw)(y!tS*E$e zAjPRF9urmgE4@&~C66u2fc;e&TU1($+x|U(fQW!1jfA3f3DPkl0@4D~DM*8W(lHKutYVu$A0avopWY0Xij~2U!nvp3gu)jxBj&&0?dZU~#vGBf?h24QpTX_Ra7RMRiX`36(R zfOWjS41pVYOH9P74#0=5G*s07XIwykvXWF^x<3wt^AP^GJ^?%1^N|j9Uv;2OW#rb zi4SyBUzi$-jM+Tj;?E||1*-G-~9IpQiX&h6Mcl- z8^Ba(krO=(F}xuiM!iw#OM|Ck^n<*<|cE-)Pq*a`**X zisAqH;3FA-{sYaV2PtxJr_IlU6h14Z`vTsPCikc=9-^>z2e~-*uPkNZ|EGDrEVH~g z^lm%X{e~-XzBsUU)T`@b0!+<=E9>z8-zPI^7G>(CTaxp zmx^(S?Zf&wOSf6*|BkUQ{V%R*#yVBNu5;973I6o5xgL{VK3yHM<(#MXIC_--J!`y0 zViMW!P-0!1kjV}O{6a)b6a4=*2DJzdf#3@y>{g-s>Cn2i{GmBKx-=p8VgJ=14+CF4wk5HH|rDaEdCrM+;B= zeco_fb<>}pa#lKd8j00S9dx+hd%yl15b%5GW%|$0-xa$V`P9G#k903wW7M%SO+8F* zRsYQ*#&dD?w!;JPD`i2l15a4=v3wH!NRbB4cP^$(>=@w4*PHeiI#dFW(Eoc)$%;_v zOru6fYjO9ZVyt^~Z@p_PmYhyZYoxWMUT_aRqYy4cMuZPM!c|CnBv1Oe9-D6T7Z&U^ zddGW>|E_E`Ua4rm^sr{u%XNt`%=|}dhG7{w69WGTdlYSs9j;;?{<-ieE$VhBuyvn~ z_m~{K)9hW-DB{ao_#I3?wR`%5)p>kaI)aC#Qo$8CE9CTF~>Bzb-$0WM3Q=imREc0j*#vdeNnSeQwZkf&Droo7WF|S;`sfq|D;0yVN zsBs(oY`gO+%{AlRT11}2a%ZsfcZpC^n6NInBXc-iaJAP5xt!}cR7v)#7m{O9U>!L$ zhnwl>ZE9+!z4HM?5pFyD7mg>jbU|1~q4hvAAe|Y6fJvlUIyv=Z-2;ia?VQcm&M)4p|HAH?PL81P&hOf>z4y59G0A4>B(JULoGiKn#x zjkFuelxG40@cz>hZs~?Y;U`ZfE^dxDZ#)+0E3L*>l3k>SQJXL_A@P-_hPkN*452TXGQBV#{dNdqZaiDN zFK#M~gH)!FV)j2QN6bKV1@$_|&GvOQXvah~6&IW*i6vDr9mvRm8^#ymjc7jEOvQRY z=N&kl(RJAV$cuvCCPzH=@|bM%_%wH|Es(Au#4iJDP+a7jI+P2DoQ%jgbb3N5M{*42 zVgb}ct=K@{{me5I3zzV4^`yAgagzsQcjkrri7sG1%^OerF1Q;pxOTj&QXmv#aaIN1 zO3%@m+e%Vi8eT#$yV9(f1=KXl!v zJQ5ukQ0lw5(v4wny6*D?*f*f|pO{es|1nh{L_7l8aby~jqtP$3FXFUyAB_?D^up}Wb4S0cmeN`y6Ktk!VI6b4V zG~9Ex#>HZyhyd)_=;njes-=b4BZ8H=`}j>kZAJ1;`_DRLAMMCLBKxUMmo{ z==F~3JQnPP?>q-&%np`_ttJ}mHJ}2f$%wpUc|{pWtTX1V-^_@JYnm-FnMQeOx%SMaZpAw zDJQQTc6q2}1~aVtadBUc&4(8hx=Yv-cVmH6uU z!m$?@cR0FQ|GBMZQ-A9qMKKr{+28xx!V>Dy7p_egElpN1$*6eWZE z`iI$iI!t>pcECx$4`EbH>Dkdn3>WEBH=#c8YZmE6LyRbY{7U<9lJJ&4MS<0scKs=@ z>nDdh`rDdj9r8^zA~Ed5VA1JkO7)apC@KVzsi@pkZ&`~CCon5JJ@&)Q|NX%O@5W;g zelj^ry6j6n#saGC)m;_4f5nJLXr7jUOjG}e@;L)L`(tG;W#Cn?&hXM}`Wy`+aR6Iq zk+Et%kh0tvmI(N3$pHknLhdINZeW?*45dSfi_va(0yt%@{o>Q6rm?&Cgr((zH7cpe z$p<%iU9g_$R)6Yh8}aLqk2!9|mjr3s_RjX7KNEVg!r+m~$^F0A4HhDxRWDo*=wH7s zij0aGE%f!Hovo=|}{PNT6ciS0fAZo{LuKe*X zqq#dM3dF?fnXD?eQtEg-&d%Gr5NTeo%#m{_CIf>Mp!j(}{CPJ8u>k-m_DdJCjb69z zf_OoA5Cx1 z=Sb-Ta$>_pw$=t-tIyGYrO@3#aqhbYlLrvu&Po_7h?Bj zwz*@>U#J|;1T>%XUROYN%jB7V{rPBxWguCk#T%8pfMQfg&kNx#Zn#O;o`C-$d60+& zYCb2#L;IF*I^?fR_anUUrBp6Hjbq-HF%sOmeYxnpG)geAb+hPkW#4dwjSXj7cM0VLnrpEGn>}O}zIE1muII zGwfHIpXgNDq}ksZ;;-~-@ zrT^*6FDp665^RNodw903KQ9|#JUnx7j?SKG-)(9WGU8BW{;WC!9tnRp#m zrCkkU0EZlUpymDjJLAeL^UojqmA4P#$;|vime;qI09P%((U+!0II`f@7 z6(-l=$sjd543Ktbd$+w$SJ?@@u1?k(&pSuZ9p?i323DfTxEytPPTjL8q&xiViVXr> z;OK7zsm@5xHH4?Az-UBp4wegqzz3O_o6F1z!;*xZ5&%f{pz~njq`%uAKNbL^db^{t zODlN`HT}Rz0)@nS#$;XZ;Ica%4@};WgD$!mA&<4kxI{E-{^S)HZCJQOFN~ViK(L+M zCruK!-1U&>)%6pjw$6T_8uf=Mi?tQbRG+pC)YJ%uKgI*)mhr-*Tju5nNU)!4l8t03 zvH_29D@e5TibAx=8nxIPiEi1OrF$5@?B4v6&zf|LRyVa&^HEjY!ENJ=xWc~r9A_$7 zY6Ic~EuLm5nyw$256SS6n?U8!$$-#fcvcGat}@R;Xem32smycgsm@Eg z&YziVT2(ttz2tgB$A((M$z}%SS_jH9?^OvK&HPI*-yf3tL%x`K56%if-&KF2$?_8N z1DHk7_2w`4)Ikx{ZWfoW?%7(lADf2#D_z=Ov>UCYN~ycS%bO`3wgup_x=l!;dAhE= z)Yy?CeckZ_{VpPCA4Wy-Qa{MOfys;{*xEk#2Ir=MRH8M6H_>km-BO2TGA{!a6_vgO zz6fw=`YjW&cd~u{rs+CK)Dlw-iXv7FKTD~qtdz1f^#{o$o+Gky^Fi|q>Wmuq=YO=i z^kUkxtv1f)oIakuS`hdXcTOcTP>nH@bU@IB6v9YxJz(ijGnI$_eaBK%p> z87+BnlErLySKxig@J7K3bWYho+LGFuIs<{rR!QZw=XHFJxCT2c=t62vPEP4L zSg~Gh5BN8V^S!`On8!0<`EM}yj*suy?^n^WnXdEuo){_JdqmyKwPf}zvFa07zHyp# zt?uO!nG80`mS%3~ts8BFFq}04F{E)nmYO-~#5rZRgA4ijJpcV~)zxjL!LzJcI(KT5 z;5rmKMtD5wz!@Kum?6i3+E{su(S`58J?pTrz}@MnhmEUj zYuylnRwAQM9ZMa(cL^E?MsuTb4H|Vy!=<>_s=)>Pld?_jDjii~$8V{{j~~gQCA$mn zgfuC~!rh&=H`Hk$UUd8Y#qgrlZYH`3uI;&cT!7vCxY{$OS5MaSi)F-O^l$3cn3vn? zn83yD`RQz(6TX0eX2vbrH1an46EKbNS`59qv6^`L^2EKgmxE*=Ohj9>o;hi>?;rJ> zwXiV(A+NTZ!#fzQZ44XZjRIiHU%Fh!SzKOLbalN0hWGD4_gWX-y8Wru-Ey-*(~}K{ ztKE6ZTQX{af=t;cX}|%0IMk$DUthnu?J$-%u(E7YEt zQ(op;q00sCqm+UrP=Z_P2k!d1nxxo(x~A~zl?M>pXQS+ouImUy$M*4r9W4gI;i=?{ zCOx=9FbBWhovzewbjJou)Q6EQXz1PxN$@KXsfM=5n`1i&*lz;-Bu&xiNsvaoTxfFv zptd4~QJLpY*knEorE_!2#2VO}A)$viju%QnM>#r`tfyP!tL=wTqW?<$V71lNQ7i8pPU7L6R$mnBbEc}h6C!`}>iZvYncYY|&7~9A`d3_Ee zjtYf_Q{cdyK$)ncbp$F#y|ko>i8<2T|MhKf`Z?R|t+D4sa>P*(X6MV^s02Q<1<3`? z=yEv7rRd$_INhWit2dufTy!sM&gs9qxRrW%o(e8cMm#OmJg!cwja;2EYfHNd2D)xh zi20mPXH}qfV#TMqt&~-uFECx}D?a&Jx7MEk&@eI(u${%W1%`85kBdT5+_z)_Fjg)j z0C{B!IM3{Qf`Ks{3Cl}MzFwyP1;#y?FkHPjjJg=hC1~!8H+~X#|{4r;_uR|iCC%2g+NpOC=lYW=b zYs363&WF$H{rc2btP415gwV&%+PqhutLYUm^=dU*VXI{+EpH5?K+1lh-p(8{ZJwT= z&&C=ZTx{;yZLZdYww+Opc?lsAcp2^uAGihCBX4kImZ-0?g`gpi%bnqC)rj%AW6|Af zS3Bc$6N`x|0$SQVki(CiZ**Ua8IA#u-y;1!FFt=I`vxdceCq@2#Zv(xesR^!r9cA} zKp^<#ianOg5TnBCkc^`#-L#jCUBApXQ@%ptR9XCzLIxKXU}b}AYFyX)b?-JG4~q4E ze^?A|&WeBwTHJ8-bl$)KP~8KXo5j8b$}H&4X(9q>kymU@j~RVrR865v}{rqc~&nl4avY zokX{=N(=Vu_2s+Vk7Py?uHPfIefpQW`!&w3ZC$zF9qT-)b-+iYr&V*v zbWWs&8nArBz4KrB_t6UQ7zViPt6+HtbBwA!3LiHc_IShj3X&dOb`iUTRN?{>RdtqY zdA@!R*gauy(v|@Nr1n#&Ty6t8m0)?tKP%c2SRIachfV9CX~t#CdY?;uz4skmOm`2q zD)K(S=#{rpAU|SqKbWyNiws7X;x5-l$LVKXldv+5$aBxwIn>y*MfGRP^OI%Li{95V z8B!d6r~OBhophx5A4|^5g!-0y(OX5f3j(SGn>K2L#?`?}z)k!Ejphiv}Alz1IuFsAYV!Lpyh-elaQ-)_W+Y*J&q zxBdG2H^LrVSwn4x?|q1P*Z6&JXKgKY`olGa->Fx-hgzBJ@k(MTTL1<}UYwp15W|)7du~y)gxvq-(s8zq zr%Ew}1A88U;CB5}jenFs*4e|aR?WpGa^CP=UuhjqFH${Eq7A=yK>i+ndNh4=Q`>tA zldPver|Q!zmQ+7a(&*J(ak1$RE2kK#o!rTC{}&u5>pFM%1`i_Mt`|PA^6}f|*ZM7W z+ew}>9EV(?Z|xt;ZuN-OQjZq8)*DoEe;+H1)Q{Yr8K&QX(0v|%EYCa&Ez4)?Z>o`t zi|56nyRA|Bit)n82T`>Q@ehQsTC(9ONh zn314won$m6Q*NB`T-Wjs);>Jvd>;5{(H}RpaCACepTP?M5>GCkUl9tBC3vRnpdDbLIqH%@I#AK!O(#{+_yUKO+RsPe>b!n^qIj#ID{ zuoPL?`*?+XJ)sI^_&j5%3dct&zUOzmk#Q#J<`w(vx+ zHxA;9z(|nt7-V|g7vADO9XgN$UIDDit8EJ|3 z;kw9Jr)X0cY*6@g6=j!;Og8);Sk1W)=H?&0aMp&0N#y!%8Y+88wRPl5*MEiV?%SyQ zPk*XPieRrZz(w@@KsUvIW^GLD3->C=$h(|gIOn3FYH;@vxx^9Nyp{kMSr9K_zS z9?nvno>}XiW^B@9m!kHZua|Is(e>fnD8}?yS=YFnlv%iMblt`kdE!q=4W&i+s;?2t zdqywfugaYf8R3Sujg2-)qHlWmt0BeSC6nR`Qc#@V;uIf@))jkOi+gz2v`defBA)iD zu1>5b3iRji^wfJv$i2jZM}FQBKOuMZ#7<829WYGU)=1xu6+jv5c{|CT83x1faDWtm zM9Dp-HTk7tWwxIK;&f0`NcU-L zTWM)!CdeXU7hRVSQ&7AGaGqXI_$R`wZskuac_&TNq`lRH?xh4wq}jz=FedsK;;33*}nPxTZN%Wh6@3k??6HdV0O)egAp->I?*nr zeVs|xcAQy`QoQH2*~1pV_A-u7NX7Rl6o@E+m-VIf{9_rJt|c-}W)p>654Q=|hEj%p z&)v9cErih99!Iaa-}N*6s2;*pn9;TSm# zEc$Ip@MVQ*cy-h!>$mpyH)gjN3=yXr7|n+$WCqFy1K7@m5T~fybvjUSpfyuI*52O! z2gHaI^vrLsR}f&b0%oD=*`7t4V#c()*jwYgvLN#pmgrI@^qC{<&&kciL>QCBfHFLy zeA9_bSU3UucpZ(hxh?AqqlS87H2J6JN;!moLZ$a!| zIxH)ZrP_uWBUVQ}#s#Eq%`q zj#k%kLncYuG}w*u<)#dOE|;%da-5y=l_GQG%VAqR=Km2?FjH|cZ9m2G!7a+#qV8j% zq{`Fa)N3?Wsy5)_rCv%&Nx4rcOyVTwmJ(kP-+M-NC^jrX!ch=nSJ2{dgCUE3l-}Z0nuT+SGd>tuYADeoP<+ zJZ#bl3~ZRM07+1n-_C3jg_HsR=xsCAir*Gc>Ndg{P9rAgtV9Ujt8B@xa93RFq5shW z?95b0pxL1SGz6ld(#wpBT2+oLK;?`>Kn3FGor7$e>8kmM`TB_y0Yn0~?AfMz!ES&=|J&uNzDJ&1gE0UZtC7?5-D)Qt@bX`mLb z$HL0J9@j0*v&>PZ6}$=_>G&>3~yT0@(tzI3~lV=&f$_bP89C#~2+7$oOA5Z<>PKhh*G%@Jr9 zgnf}tG#q&o1@1&e{@=I(fC^MN>IVF_?_oh70AnF%%t`}w!y61Eptbni@U`0rgQ`Hd@2HwdadG`~*1waf6<8D1H894rqyJ9#*Pftd` z1@_xy97Wle&lJLr%rK{|67Up&=vmrbmIQ%CP|4*jF)=ZkN%3NMcr-^l3M7p{Sk)Fy zo(m!8N>|Mlq#|ignX3op2jkiLeGrB3N-*?(esP25&r%3k0WuA(^xztZg~fZpzNIrK zpVjh9n_Tk44KTD4a)mW(KL)+b{&U%^Pc3&^^PHpcy^ENW#EZF8cG=sqG9$5myZgQW z-lE-(t(J?rLA(O@Qc3NP=!UQ0>nM|ivcdP5H#;E)-9EsGFJBM-<-f}R)L zK+}TJJn1se!UNgZ-0TL{!{O6m8MHSqO8oX0rJ+}cZie*Y-rm4X?U&jIj}<)ilFs~S z9?;Q6{6~OV935W*%GS;8da5S? zs{jv?r?}}o!@;EP=-ya2llHqeTceAC`%NamIFF{p{rK_2qh}vHk@pef12A>LN<=a9 z8v`)p0H`Y&{nlI%L4mOWi;0N|n0SVF+S_DrEb_QN zqL6D5S|AmoqY|)e1+`;ZClH=k%`Sl_Q$1jn@Gs1IeuxH)kF{|g@+nO9v8J)@YPp9kUSa+g6`u)sAlMKPh( z`DpeSuwwZRrlN7-p_C#qZGnU@tOvjf^*HXO!&5YN6%+{aCBLyFU{Pq&lUp!%sO4>%QeDQkyHKnz`?t>H*Uv zFNnug>h#yVFxeQbGLtSsfRnKpsp8n4C|m~iK{PoE_>YYN_z`_Z z0L!@MB*bW&N@_w(AHQ>6x&vh!%=-Bj95o~LPwbl@cSiY(_f|92x5xXE#i)H40*Q{t zCJaHGwzVUK96g!!^z>Z!=UmX3WS6C7Svk3 zodQb$YjhN-XL%6;MK6!VS<34nmLro_U352s9{j*L+wj6u&@TLOH!#?`l^H@oLSeA% zxx66kMo=-4j{#Io1W;e}t`D%wo5)EMc7uxVY_VKX33=*@O1De+N~z-gS5Dg$m*cvR zg53916{y+eLWdJN6HDdGmyyx>zWMcOpAXN zEDskKUEN(bCSGJ(s{)7Nfqz&J1g137pNz&yVDYD^D1&9~~1fP2NCe6q@k4Rl&IsPr`2tzTpUPSTIX z+(`f&@t^9xGxkU{0xNnhE~`}l4Y(2#6tYuEqH&!_AZu!D3>4JlMme&w9Sx(_)X;c5WX&QfaNZ%d41~4)DD`XYu!^&9zo%ub%;WKVjL_u>~dGymaW2qeq#uqfMJ!#A3FUqu8w z-{wsD@#D95xR8j*Ht9hN;J>>rz6Q26ng2o^jN8r-dm=^AMIQh*56mow>oZ$GP&(?r z28TMdOA%B83j*6Gu(bb2=NG-Rd)PUrG4?)Ya@)-)CW-=6Ii(c3mGbOyN^UAD3ID5xSwxg`KU#+0&hPW zF%I@+4&AC1uxbCvwx+fMdHbtQ85mIVL`4;fk=Jp$19u$A_#O^SmV!S~V&8&^hP6`h z+x*2{?!wx@)e?9nCYC($rT!L)=%U-Eu7yne$8z~`r^W%aJDej?z?vr50qdO#2`J1Z z0lFGoTKI+SXB~I4nVsWPGpS`;n~`x!@KF*x ztR-75%gEMN5i}7Tt@QBm7NY@hbzwv{Y6JLLfj)R`cQ^xhbIrEQQz=>Y-m{Vdz0fxR z_WmbM4;fMleMCbl1zyMR09}!-!&gvHus7ZtNAdo0mX5VDA=0kVKb=`2iCY3H{b!MH zg0GeC4l=dzIy@r14+TNSPbm_y{E@)Kqj?R%c=#^wUx;tc`XJ2woIQI!8M`;x9TWLp z$=f}q{t+quy|<4r0Dv|aLj)b!JQc}i6~%hmkD|Moh{jPqUV@sc= zq0{%St}X^%-jTi8t^Fs0?k5(W7cICC6d3_TAw4heUUqOWWOA=OCN9pR+EZVjMo!+g z+tYmcA2RW5bTqf-CYX(0o;WW*{~;j00(j(yl^hjk6!K%GH&oE!(kadR^4QTKOFc@$GV-q(4oY=?l(yXZ1u zxc@-6-i!db-!Je)mKdac3AAH|vz6&Q`OOKRGB6Bt2$18Gv%k{tda5md*%|XIDRe}) z8WY0G%8F(V0JH6BED;|cA9HT1IhfuTbNjGBjY8dwni>kQ#1Il${p;=PYu^~oa9CB> zOuM>qBT~y+QTAO!H5yhwvGy1!P-b);iItqi-D0c0sr6DhLAaiFQ@b2?&Yz#%dQ9W) zu%d1pV83<)W<1ZTl;6B5>X5!yV{1e#?x+%^uFtfrSIM!M-iZ5~-NU&yk=KR5St0b| zskk`p-0>0jHJ}PuPjJq}z9kB!<;9`%JiyrVK9GPk|H&`z>?${3_H^Q7C&L$Z-uf29 z!gA4dJh*HR6Q{px<&+c&ZhUW~r9O0?Y;H`JGCb3k1~qJ&uC~|!?$h|ZNS}hwy+UX4 z2+nKvibTqBu($03@6k?`lMxWG7*yT^Jj>TQUZx@GUV(()L%%oI?>G4I+fFZxZjJ=R z;;~7GQhWv8;v98yaANHM=VgCzaGB0nz5^jY-3wbsB3*#F_X(>oRW-r^myF9GH0STz zK>1tIXcC5{=_b55lv=z@FV~bxcqB#55#cJK~>o;y6@9R%rSGN9M$KsB8Z1EsUI2PAob zXr}7J?$#Lk>;<3=5QzueA2;5K@;R{rIEYcH)BcX5VZf@CJdmi?NJfX@I5y-W>Gid2(iOEoq)X-&ug9zq|1|VJ33nLBiZb0 zkW9}$9BLz~r_n8$6Q+O2tC=bl)^Pz(O+{%140@mJAf)UR>T>)wi6AQI^&{?j%AA>< zy**Xfe6ZLS$76aA+-DAsm7$A89(xX2 z;66hRB(FTQZYL`2vCn2iuP@DKHK2AfVYFf1jhBqzd_7!-Bxik8+(680=tT<37j+?T zum3ZUylnXOF1xKRx>XcS5%O2(UHF602v8W|cTULzfpLVl<_9ozf7^(b!vqi~R}?BE zPaM1Zqod`RGkTr{dW8EsJpMW@zA$%{&?B%b_TZLApl&@0x{RcPD|gY|{d@DU)fIU~ zT%1pUkcWI{E`b3h)5EpqxsAEhUp?5`F=?|VN-?H@wwnY0f%ivkRbxfWduTH@@|CNR ztGq(uN<#uCMUze)i7&n2Geknd;98%AnHggMAvM`nnxNq4D#Avj!{1w9VKJVJACEm2 zLkifj5((X6`YyL~NJM$(-JVvb!7f+Hfeunq@#md?0DylurfImiC_{^P(Ug$>fj6S2 zp#7*i-16bYVG!X1F*nt>ceUBcM3EElCAyKUCyZGi&O;eY24QaZiVIKo#hmmXVlTvI zi#*NFJSW1ys{}L(lfOTnl<2U#UarO-R>M(Zqmowq6{L;Rsp=T7em;Bq^JD$o-%LS|g6%ygu2G&l?cz%~4PdJghmd(n{WRQlR}y1vve2~$;*VEn6^ zVT`-ddX$>C+n;@wKKp=9^&X0VBc05T8T!_WCmfzWJ$mbo|A@A3wm$Dyz}w)5hm#va z8Xs-6;IvZy{)*z4)mE!=TI?|;3P_As^}p`kIK-Vt|E`DStFV=U7w$;#GM35?2*6Yu z=2HchmX|>3;(VVyEC>iR1%Zue5!5mw1ih3;8O7^c2iGjJBt7efmzDAKff_G2i)SFM7o!e^Q$s5tYoPo;9M7D2Ya|%dN3nj+ggWZyvPS!>;mqevtaS=_hA{Gz#qXn_P^yy^)h-ujQg{ zt8d5ggpRu2$sq1!$IPeu6xd{&fmKImrDs+o9s>7Xz1sYTJQ!N#IE=`Dt$V7lxO&a7 z{^BewgBIE{q3Ja(ZL%sU^qnWrZnieMR?x}WT^>F@s}AdHsTTdKnq zzYr_()_3DQb?BD;mxJUAD7GdF;4fk<{N=~hTXm=}7<^`Xdo?{_3hgk}*CYHa`fh)) zXpGjdA9r}_@^I~@r|iD=kbag8w|DC&mIdoh#0VPiXa($?z`P3q5#dG4Dc2eyofrd~d~ieQ5&8x36a>^v5^Ht`a<2`GT6b_7RA6$qAzK%gz4v zUgm*2^q^69jN1BdOYaYQIo<1$XBW3yu4}@(m}!6ckx5`6F+$Cv4_QRQ#SZmL=RvH` zKNlJXl?iQ!7;_zNL|!d^kdYwB;^b@%z-mGeRhny%m?;c2|4?d}!*JVk^d|+MNmUMK zJ9VRGabOhX6(^`Rj9KZ5RQWP?-~pjR-0n|zYZ~9gJ>)!Zbi9=FJUyklxdaVl%;%f; zG$$*nb$_a=reoa6h6;?5%Kr70l1%Y339>WYSie&;%s3wFR}0l9yu2ouX*#38cJ+QO z*nprviHAJ1YKnuLqriPVR&uTNn=Vk?heuXjv9t{Ihw&{lxDl9d5njw(z(1bdyZ0o~G zaw)Cy;(yCdI?v-A&KEw;LK~hNKYfk*g2hKbPy`uWr-4-1M)|L&T(OBK*F*B*Qwxp= zaDBTt_B(%C?!?w`5FiqZxo)p2jCy(D}dbRG|;Mtct+pU^*jx<4l- zNa;eP)EUv0S8Cxw*(Bmgo87}9sP;k1E$mx`>myZAM&6S0Yj>lrk)8qmv#z5u2CKM@ zyBOYe4bPE|Zt|Wk7ossc%w6X)0~tAezU9#x??)$Tlc#vbzj0DVTBH&OHBAlIn7{q} zT}DZ<<$I9>#V{WslN^WE!52yml+IUQ)tOngqKcVnL* z{Y;1*u#D7lwbR{U%j*D-FtPhUps`ojj7-;oywdyQGwRCAL)^C!`zTDdk=WicFz!J_ z!r#ab#+)6_Tox|fa5!>P{Nu+z%K$~xI&8uOfp~Fl1)Ws9XTNq2>-Bp1O*^sT9b1TF z_kFwNtB-GomZ!1Q5rIX;?Wcvx4z@gOSA(kkN~;4%<__rxKy;SozXSLeVak98WK<2pM9{sg40(qSp_brQzptjO)YPqkzkOguQ+AYcblqKPMmU=&CBD`3;Bl&S^ zz+-Q_!Q#c6yAzk&mPz0L>xas}H<0kPh6ba7`zZn6rNGCbSF^Bf0NzaMScDELC1Si%FY|6xud3ja9jBxp2^~!JPB|bmrp*v~+0N+i z3Fl)4OVV939ygwXNNG}^&$n&?HuVKlQI{XcCkBh{lTg!wfa8V!@oxn82!+Q2RBRfB z9YF{sOb92nJs#5j7-iI77p8S~N)<{c!t>kYL3A*?c5KD)SsUwK-cS7SXoH{Zl0|mO z9Q#97Yz3h@imGz9rw&ZAuld|E756q~q^Tz0Q1gAd(^ynq*Wkj1aQ+=f7v!I-n*8Hk z_2)*nf^B9<>t%3cL;sMRb%xeG_JtJal@q>rF&yW)>NIM&)^QNq&)P&BVaO z#`fEUn_69!rBE{SJwpgvv{6||P5miBl$Tt7n25ZU{}mN&d1LyX;*|YK2leVH45{hsr+cG`{#D0YFvlWyKRY z=QknTiCE&&f!Pp&KV#yG{7KhxN3stbmEotAL>Dd|iiLKLo?A@$&~tLm79qE@4`7%M9qO-$qyEP{)KecABlG|YF3MS}T)NAyL7eg8DY{aHT|tRbad z9L&W2Y3{gn@U&lSW3URB;SPuA_td}ZgL->6Re*!we}0bW3?F?hiz?nIzCp||U+fCJ z*PIYUWJkEk*d1Y>k@}R~u9iv{GC6qh3|SsMedo|G@s73lS9Ys#2tG0xtLktPY3yN@ z53MeiN8Wk17qOh-h3YxHyKv~@29p$p-ripNuHF21b0YUo@SwKhEp&vdbZ5QJ6-i;k zV{ua2vG0F4$%aZ(cKQE_sv??%5@=E5E|fIP)U-TPaaUU28@J1Ikg$h0Rd>HN1_s8}1~-ML`tb|MpJ` z6CziigMnhUq_`j*6Ec*hAFgQvN@2a3DY&y)w)Q_-00l5UwR3??CSR&TwPk%R*uoRs z^NUM9k5N4j+_|dCu=1dJ7sY$!(Yjg9)?MY%B{XBF_I(=xt>_lk|16Ji_b^DNSX zHljA!-!S14=Zr>hWlVf9I@)B7dVgV~beVQ{`Soo5jZd4J&+Sy5JC!x_mG8ohWud|O zHc{ChSu>XFuOX*xrK()?(XhQGrSR0^bxD2-^PKg>;Tscd`u}c<>m#X$zVyKCP36n{ zdVDT*u(MRU_(YF#{&GJMM)B3*HkCfO>IEL=8X6SD}KzQ^iaSr&Nvs#5h<_8k}eTT~Cb z7uz=L+{;3Jz5kEBw~nf6joL>yEl5gBH;Ph9NFya6B1$(>3P_jIAzhLRNQxjG(%m8| zASK<>-F@fwob%oK|99`W{NMwd3s3 z$Y zJG>~SiSl3|nePvd#6sogd;}y|7&C0A^Kl3f%<}i0ocQ={j`4fW=jhnQ+{m2D%Oxo_ zAea~(9kV@qm20|G5v#?7_cH`_ToZ9g(6pDcsZ-WdG(c8B{;h|Cr7B_cZ?=|gY89eJXO@KjAM*Nt2>9Rhl zryj%f$lqAknRxhRlT+4ve(PAxZ8HyoIn=wG&3;vBd5*Z1RgIC6M&l#A)PSF2u5+gn?Oke+gHzo5vj zIdnf=OMf8jDV@g6tLrz5_U!alIVZ<;QX>q`nVrcFoh}5EFHAV9%GU^ zhMv~#hFEzcsf#~kpFLrSa{#Z%MQ|7$;(MDB%QQp8Hcrmxgrd}`A;~@N` z;isGd$QUsj&X_mS(nb%Hj{4a!|K9Dse6ANO%CzR^?4oZ^Jv|4--=W#2;yT8CaUk2} zJH}!s^*%AVPjRpy@iNtb|2o~m*^8cZy2U^#7DI|dE!ddud_%xiIJJ*0k!kwR@Z^(z z-%&WaS9_~6UA*;#gJCI5>+{23Q^r4*Qd}oH>OYS=kPmNm+KdJMVMfI>Xl&q;zcx47 z#;1Jl^y&=dO2+e0%byA2iyHKQZ0N-dCf$33gYGL``o-pTa~y5{HDBIDUo5J=QOQc} zv&S))myFCiaDRX!WE{X*jc69T@hhq-KeSuFg9QwWC(;%54z=pGJ3@$B6S^-2Y^$qt zC;RF03OpOHL`W8u8x1BzV)l0PeRjvDQn?s2Iy&^OMIq;yU6)-v`}+B;c6S1Y)?Rn@ zXhWIZtL_b2Z`nA-Nior3PRoh`vy|BsIXJAprY-U zGA>qUgli%^Qw=Mi&ZvVjJ-2DXyT5!2K86}uig@4EJ!^wiQdM`U>8z4IKG4Z+>1bhEza6! zNw_vU)k^iopO=jnX{V(PMl4jPM*W9J?kgMwD4Rn9ORZWg&##DyPs5}jClZduPpuv0 zXlBgO=~ShSbI;FOC_<+IifrJVeH|E!;7n=7y|LE(|DK{Ks(+wJ7S1(y;jVh-HjGLw=9zZaA9r?!d%}?C#SKJz#j9 z9WLxH@ILxGsi3tOx_R@}!Nt<|!=vJ#9j|^88RLa`tf3L;kd>ubc0iS{U5}}rH^|+! zM1WF@m6lCao>#X+&-t~~$TXqBG}+E(wjyF$$ht{SSBv%W|LJX9!>&1~=aF{Db9F|1 zsC}%jK9siJ$@WEJQ@@_udQB5LHwU^(qaeHJ(P8*sB8H3&96#t zA)b|UHKEAhQTXZ!N;pScaAL#t;pDrKvyVoLgI7Ck_AdN9&cbJZ{)vg%J`oZxw*!fb zzO>tpBk_;BkkR{=85_W-Z7KDQ{DQ|=2vXC9Bh$`DYvbi(3&j2&EFi0WIN#4H>7vpP zR}!K>INA4D=a5f6K}%EmBdFKtSsk^cU2zS^;UEa)1?x0&ZZ-7_3kzw2L@PjH2|A=RCq`B1G$uYZp$`6B=y34~h za*jl>Y|upH@72uK&D)?^bwy20&AKs-jg2M4NmfYP{38=9>*x<>AD~#ib!`8_kJ$6I z8D(+NEz~rU>)%F)LaVfI{NXHoxgYmP*3M7iZezQV?sA7KQ!tjjC&HWkHNV&X>WzYf z-8s%ej|rJ%zJJc0v~)8|H!tLh9&5T@E9c|mOjsYyVmi?uxC)*l{S}GV+Bg4jYKG#+;r$eNwz|N>}W@ za2-N2c=&(lWVJ1F$M(U?v_1Jw+3Insu=M1T#PHnojpwH2&AcyN#I1s-YXTJw?4yT7 z(>+f;y0a0)doBTC?n{y(j0D_L$EDq{f0EkiunECh5(;+ljI3A_k+8cBAIYCdmWl)8cXmFaC zor_x4i{(Dq&?|j>PO#p=8_aMeKoV%@*o;{nGad5YS!BoHtk@1Ms##y`%srYspsgWmS))kw#{*w_i1y4H6My|}N<3|; zUC^ikqj{Xlqd5flL#8V+QUP&l))ic6g&bvdK~(C6gPrN2Oi^}9r5$-yX5-HQ^{ZQmNTS^|Igb<5=KS!qO!&;c^c8Zf5-^cw#&n)^eP1peyOo` zoeYbHqXo#*dzi*ZMSoSc(;^?P=G>3Gt@9?oJ5KRr#0NLqhEJF*ELk~)pMARg+Kfz8nW1^^ zIge^wj($VX)i^cJ&C}{Z4PU<8s{HD(r(-ptmZQaCtDSU(ol^vMMPmq$hJQ2+%#Be<=OGE-4NTqA~5Y5Itn1%zCKWvdm(>g zWo3mr{&Qhrb9$>BkM$mDua{q?=563YO6Xsgla{3yW}|xX;sqWUTVb_|)`e^Nzd`{a zG&3+zh-fn>zokwn(OQa+gVq8ZjFq4?~~LX|N4ZBnR6=?=F)a%N+tl z+haDK-=(IjI*R`;WI96#V~x{#h?vI-y;!=8U*#|(4@!crJCA*>lb@#7rGytRT4Act zH`nKLg(*0ph|boT^E(@}|HX1t9#fFc1!V-iWh(UUVxG-Tt<3!R@%L%B4+j0c8d-{<)*EvT4Rj(zuo8S72owI)7^mtor5uK4ArKJZEbC?b)JA` zjMk}Wi@l*?4B*p%go~4xz#Qk2pr=O9*GabD#pBWK>e?_^F@^ADoY^9Dm$6cc8TO6GF%bETD>@bZx+20DWKz4SY2;*5+;LUO9Wijk2ji;VRY zub-T!`x^IqsaKdWbruU$q807SQX1#3_{}v9IsId7!mWL!evB*yWn7UYwB7$GFX-?G z7lJb_%~FA2IlV|0*1$wOc=%8PO0I=mR{ZzA+yy}XeM0o?sm=4_dV{6JBo2-QpNtE< zyA_`2Dle{kXcRDNdSvnsbh07NANLrfo*H7%t2lK0qiHjG?bNYzqL{mkv=(aX@;;`v zOv#96L_d7Y@$k7lK)(pS-bPa2uO3yU;QyiZM3+F&%VKS#=S@L&5 zbb(IgvEkIn;zT->5VzA~XtRdAGJ1*84-@tBtb)xzvQpf>wDPgk22+ubcA2{v zUf%;B_n6a(d;PngNm62S#mv2Y#Zn3MnbY8hmn#1~8@m zemv#c`pr;7cV&U{&5Ua|s80xW#V7a;PZLbPMwLgorP1 z(TBZ%XEsR0NE0Q8nJF1t2E1c}uNNPfwOY21?v7M7+xtw4w=AF62A3hNY#@Y3>O-Pe z-@qkVsUMOik}blzS*U7pqkuld+2hFV($`Sq?I)W-*QiPHInLR_pOFVn;u}-XFP0b0 z1*z%&nLS2E<)d?2?{J~p%^wwx9Mr1G|Df{Nt0R5df5Hs#fzO8s|4%JB3?5%A+~TnJ zCz^mup(uSn@Z-Vxt-!{C9{m&aLPcJ5+D&Y247Nip|ZcGO^xb*WwPkwcSj*NVeUA1_JZ3-QC63 zm>#Bomm5|jPldW_NCQcXRISswp(-`+I#QW}Mc_QcpYC zxY;fe!6E+18$G8j$y9s zX1@5JJ;RVIk6BfiyrUyR9eu}Y1hWQjU=i!T1%wn}P+>HM_v%NQxv)F5|C6nGE1Xd_ z?|W-l+}7K!gOXtEzbwvMVNMRjNU14R!+$}+w`{uVh*HyTe)sf7r#U6L1qJP4ly{OlGBY!` zH)=OA?WAXzptlmhCATd;0QJ}0T%>r%NBJ0A@XoNFo8Y-jH=(F%Xk-F_4B_rp&beGwtTF&oHE4S&BTS-$?e$X@s3Y*=D*OZs1Ld5vfSM z1BKi7TiZW}ZjE1cGvRg&QtpKRh`sTY`^B%1j|rIa=l}d+6c8BBa{^jHEJW4GN?z;9 z;s@~mpplHM62f_P2oLHBu5Ra8u_~vmZI=Qx68WJ)Pa`7+;Moo00Gt(gx{ExHD+Acj zW^d=!dB;t0ak{Hyw~7@JoH`Y_@W*H1)ZULE^|l(}pN)rIdYxclX9x+r0>>G;7*UA1 zS0%FF;NWn)?wmSbz5e%j`pIKI{{Y#>4B@;n9(>3A zXBrf{0QpUi?Q&Mav){!=2ya+*GN8tkfuJXZO?j-x1s&H%2bWI)KgNYmBjg`U%!Z0U z*117Zp|qTw@nqeroV+|KsBz%-JadJ=%mvbUHUZ8K<#%qccBOSVR6T*MW(d4af{sgs z3JMC)P{r4%HQ+OL<<5>h@FZP_C2Vhbb5%u6?M)y+Zj%ITWFaR9m0xe}Is8d0EX0RF zw!h;sww?b<^gT&{09@}4QB?0+-M?#`IQVTQA?ge*aKmR>1DQlcX%JgbDQ67t3f;~+ z;I=##1MJCycBohcw5n5iI~Oq05NO>3oE3W;6I88%1mHoUBgV46z>0qT`n3r#^Z~iK zH-W^@@YhHApvXuU!IZ(lLFANk)+{jsD{wPs#fyH{2hsjuBRzxaIpp>fDteS65)9jA zx-FRafwHn(0;gVYqnFneU+-VPp1AM+5J1++ef##U*!|cZ(KOp0+6NrF&^@nnYdj<) z2ny|H;l#+w$Y3Dn3_Pz40G1-Wp(uE^lg_KnX;=?Sg#=)E#c)8+cDlJB<}=qBDF^*g zih&CG;X7_g4AD~7N2*5ka zz~VrK?8icl$B(_0lK72g+kycR%^rVC%eSUR$Q{bbzW(^p4(nd*vZ8@Z>qC*3v~0LyIL86hJniDLFMIasUAjN#rrO5={OW|*R_ZyZHAs|)bnQ2&B# zm);RZ=?AC72!xF*$j(8x^7V;Y@;vo|77)-7%#-7Lb#!sMIR_$JCW-$gHylvdW6S;7 z$Swb9YO|>EA_R)rbI+kPcQ&u6p#|Ew@tA$5*xQ_2i^@6vEE8BB<~AW~aQyX>V^| z;a%)Wbvf2=1hMq}9S>Rt`z%a=w7x#A*yWi+-Q~sU3NM`h4%m8IFm*;Cl91;HxfzEI zye76Zks@bxsKEJDW%3e>&OJ?CnNzpwas5L;)1SMpe9ne+kPq~CXM!09Hd$%t|&C74pkOECvjK=aMH_ynu+M>YiEazm+e7BOh~Z@sunL&{Gw{69^F){17_R~ z)Lrec*Tu1U;46^cJ(46cx(+}1p7(#kn{?k*a)*_5{=4cIXUA1gGR^a>?Fv#9!r835 zZct{12Uv5H%15wepyO?xevPi0+5-2ZbDrDm>`hVxqW-XljmE3&C&sUUutgW zEY#L$78|1I1Htnb%VB(=?QVuDP-yh0oVz&k6%GerRnL_?it1nQx2%nnZG&3K>&^!j zZfI}ybkWty>efvT4y3UFRm)_dXf6i z4Wc(GQW*3ml!Usf)O^uZ5c=GXwX^(bOp+i7- z{0!`1jmt4wSghKOn(pO_Sj9U!An!r$1WjsW&=B=aQ#)~%)y7^)? zat%s{WuYnM#ZE$F&gnOh*1LdQhP{ZLplctjJ>t|OdT2P^o zZ`qIXj+tSzF9uRp%XNZnFbA{cdcHqQL`jK_$jM(C4$A)z3y|Ly&7uf-Oi;ps@Z7cW z>ZkR$oK6qczxMQW!nq3v69Emev$M0c*RO26{3{E@o!XQllzq^89vL}#`O;5=EJfZN zSz3+EiMbuDH3QIgfCu?Yt@Ao?@YmXnm?kt^lryrjVhT*UX1*ps{&5a$sSXGXa2vIt zgMEbwr%nQ7{2FJf8U)>M=~fXUm1RNYBuYv-5@F zB!b-4FA(WR77&7M7`d`y0Y?W7Q2{(+Af$n3?YGe}5b!avvB}jl^8uI~W=qP`v!S&V z#H}2Vjlf#qBaN{ImL^bt6u^E7MGq*5uU%cuA6zDbL8#80tQJ-3s7Ev3(*AM<$`d<* z@%buZ=~vd(i@j{|&x`UZpdb?TU&|NhhXn;SA*ChU`_xhSL1?@0;glC0NX2$AwnxcLAW5>+XFxX1z~?&pGUYz7&Xw1=Y(~(EzNQx!HxR`{ zZf3B3`pV3e_v^VdixIcAzcfu^Jj}4W#kK$`^q10x`AX99W@8y z?-Lgmp8?PS`+N(fT~PgjsKBB@S`ZtHLC~R+5!jhGnsG{R%|$D6s+_`BKr>V{d%nLu zMjnVyb6-g*Ty74-3LBf{I6JKGS&3kQh%ne=FzbW!sTn{puwn;55AQiUh%E!yVxq@V5Ao1i?5e# zj8~UU9K|Y@bbtGXTPbStl@Rvs8A#a`OPvZUN$A;!M|Xy-w@2QoJ%rTaR}mOw7SUA< z0R(h~7ot$X3nd#(umUvk!a#=&He+v*f%twoz=p%F^L;ZK0Y3U#H!PTL;tcld*2Bbd z5!8}uYH_VB`LNJZAfYF!k!KRJHUqnhtI#Xdb{YXfAd~eHL4W5CF+~4X%$3s5sc)v5 zn>)NnDy+riK;w6qwQQeOIsD>RWjjAvC#j}3T|L%D0lVxZSybUnZ_S=pWW={ zHT#kH271Io{>ov+<2#7<4-tLOzkj1BkKMk}sU2^c-6>V=sIL6kWUkIX#GFo9FI@YMT< z*_~Ttp{=U`8DMyO?MenaGPGy>n3m8`TAm(ybYMwdYPFg0-lMsLNSHfl2mhtSs1;Ke z1jIXTBi|Fx%ah^8*U1Rgj*oPejU6H|TW6-LN-VV+{G`07B73pw;U(CO4qopW4cUS#lrNT?!n=P z`mcZzI6pJzhf{&Kx~n#7R^Od5;Se(-YClG3oYPl&uQGoS*ET;*7$LBj%GsYk!&o`P z${O69QbLAGu17*)IQU2R3m!ZHb^U8O;kg$C$lW0c#VG`pU=LYi^mdAJa=sc}7Jji9#PMEl zrq^auPC@s^Ox^T6KVi6e6Q}WP=PFVcN%~>lt2Z!^m&g9r-EGACp4)ZH5bPzJbrBGf?aU`bH1?WS=Gdl2FZUAutIJkV{g-?jG z8`1ULx(e+%_Xu$Ad0o^=*(-V(v_S@TcI`J$ZD0Y=GWD9Ga)Vh}U0sdu1?_cp_D?wsjr z(ecXqqSWc}&ipPioEhGoCh12=Nf``m33)~9ze~`Nb0Zr;O&QAr9gtCLT{hKYyg@f1 zjZa6%FWsm3NWlkQ29iu|T{s7^IX;9#j?(QNWxAQp3!2`N`gMx}G6xBqEq{L{`v`bBb z6S?6hwt}J}5Y;0B7mSUK`|90XpaqQvQgwhG4ptV@=|dbej#p?GXjWLgAHCdaC1422 zn5f?(QF!78d@(Dc%t$Mmgp4e=yREa+7z`-n(4Ynb@jKefrhcCc*miP%-hkAfu5K6v zFVtx|q2GuvvZ{459Ck6-Y#NnMRaH$~xMZ8u?X;dgHBQhS?1=;If~>1U`ci`)Js{3`&_HtiE~-DcK2)F+2xL(}8Zh%#j#d(MtRHUx5tD2h6c{6|XlN}1horC8#inkn zrE9kzyeO1FCQtLMa>+Cx#e!2Gp*V{{1d+)ebEXTWnU%;n!W9thz3x z2M!FEmX_LHo*#~h`rq$^*dH=bRMJT+g^Wo#t*8c?0^kBNoWVeNH?y3b8o(ZM1YsPc zTND`hk{$VSXR8L6Pbvk#b{l`aT5z-|e)*Zx7^WX2CenKYB3)(+3k&3GAjgY;h^hK_ z++c+xV*+T!qIZ#-=Ll@p^$1CrO60x~Tg!r|g6mpI=fGw)95Ha_f!|1{*lL6q890F3 z5{PVhzsBAI0f)4Q_o1Pp>CC4j>z2!l&CA^2-cZgOWSg#Lx{EC=TncVPr04+!XZ_xj zeBJ76P;3je$NfBD|EQ{~&w>_^y?-AyH#avbAt5lqz;k4Er56NH-r;9-tHWh?D7LiS ztUqMDeH(9KVF9UdZCmjKs*+vMFman8{u2mZ{WEYjAnDwSoPMZ2ea&Z(mzQS;i^6#G z<~M6yX%bq0&gyDj@2hdDHR^g%$f$FkAOdpuK0%4u28SDx{>=#jj5sz>U%Eo<6V7Rg z8vqUtbfdt5@Z9dng&!m?0}1G4pFPW%cq)xn0I|2@4!}SojHE59@3^F9asaKPe;G~=) z^1e$vgH?KIyda5s$1Me;m`m{*dPmaBy3hZB0skVSuE&5E2LyHmus#s%Wr48N`o3u^ ziDqXcT_;3Bk-=2B;g*=Ev{G2BC}I!+=bUcH!}|~tX+PinMYuxhDcJiQ4HF5~=;-@D zWI~iUi%p@D@e63DzgG&O)Q2|1?)UWU%t4~7lK;8s_P|CfLH zPQ8KxVXn7_C*k#C15!|d2CppZi+tq_*_=P~Md`Gt$Zbc&cdZWnpDBe&%E31N2T_Nd zu>WE*=r_T^M|hs(del*aVH~>6uJH4Rw6^=(mJ)k;%Xdv-%4~r(r6hbZ6%= zL3y9zgXca}*$>;6%i52u$9mXBJTRk~nMAW#UhjWz@9pMnHow}?^7)Rrl;}Wl`+sA* z*QcUF@p_p>P|NW;)=s4VYr%QBQv#Zf1dgHUM2^n1(p;Rt$doJlQC}53UOTq#4Nvap z3UM;Fgf)(Png3=+K&p+~eG6eVRf5G9`e>|O;pYdbuAdIi-zNt(qj7drPF;FCn4Yot z7iqV3&O{^?wwJ1{7H>`))oMx_5#iv37BiKRS~pl<6MNrCA(Q3&67!7z zJ@?TsPxic=CW5h7@1H;2>yHpUGMjU$+8)h|dYeMFG808mSAVyn@F%LuUfnb9Ud+Av zE5a_D4i7F~kR~Lz4y9JiMK_R3lDzla9xT_0XwP+;5@Npmg5$~2cE-}pv+%8i`~5>n zYQ4E;%71eBN}%GatRxITzTO`5^u5)cPJ0cJ2AQWLr$cLm1ZYYz*G=P351jalkSwdMeVsdi7P_7l9y#bQi z;W3kn`@=tJdYLM(f=1J8K*k%bKM1E8pIUunX8YXjY?X@F9I9AJb+h&*>q@?QVE6gl zGvlqmq{4Eyb}RFx4pT4hCZW%k#!XExZKIAmx01BU z%GDb9T#x(xn?l!ZP}8LH2k!pkH8=#)7jKPexl+CMDC|egFl?m{ZgTX@ZS~DL7rlST z`Xwl`wE^w;_xUP{}~d6 zJ)Cp~=i**~ai`bSeZqC-FVB6-+Ouf63|<#fgM))Y{nqWpjV^W}CrmI_>rR+c%7%ZK z5re^pK!DAHh#C~4X(Ua-S*hxO`o#9ke{r#~ct=-bfda+b8|ajwWyOgQZ#_-V$sVMI7z1WD0O}zVQ+L^ zdqno7Wp)>5uf8XVPi>w$w_B7yyGY>JTB5V*oc1~2`q8oJTjOQ%&flM`YCDmmenur42&T}jDr7jC*TvG;+$O1Ip6tsPZAo+x5M=+cfUkfCMmo&M*X+?HG$eiVtX zn41h-2MH(8z^kST3q;YE8N#DWjleSyF=V+0Nmd6Y^_Me%GX;e2k zIWZUWEC(*?y{d@%RNevw5;KUK88F-Wz{1yLTeiALY7} zn7+RgF}k}GNh`NpJ^kH|^&comP9KMe6Y_SwoV9qKBjo~1r{eZ|?EKcF{sd+}T52yZ z1k~Nn`Ev6_6{W>44;z7iiP)I!Vbk&MqTQjM%Sd~+*fu)Su^621f+Te7cMbjKR<9G{ zPfDq@L6x%uxqOzki!?6uY{FE?Nwv-`I(i!IB^OE4DzG|);q=h^c8UFdTOV>)B{iJ# zES<7V$;isi4oxYwTvobFZL7o{(>00TDdVf{`XrT;elT-+=BYc!vzHl;!}^FOMH^ZnL1k94MmnJaGW}En2;lA(HNM;xRY*=?7JL^Xr9Ad> zd0+IjnElWF4T zU#^>&CfdzZ-QihneTDG$7AJhIOi$mLZmM+q$R4{R)qzzkxw3dL+z8S1{OT~2ZmEuYpIfA}xk zv&Mdy6CHSXSh`*s5x07Igg(?5RMi|VX( zTt+QW59K1`#I0w&a{vCPDin!VukIDrt=y~=aN~cyw}gU#PRr!1%I~iwUZYmdlsm@` z#9Im+HuFNyE<28Wu3#NFI-Y*#cYP%;{GICHwW~*{Te_o$NJn>her^pW+6h_ar=O$D zRhG;99lT1cv1+Q4ga}1-R*TjA-epI-5nPj2Ivws6Wv1d6-3P2i-6)iv)yEGSXXNOD z2Uo9^VgE|;Ff~#NovfMb72h-tXZq*xvYV=^4;n5c?h9W0Hu~EALtaxDI^u=0nSXDR zXG{6L@``k!IjU6dP->GrMcVwvL3H9$g~Z6>cb;BWA_mK6Y`#hisHhF{5z3&5);PV~9j_a= znm04=blMF=@pzqS9%;SQSHN*{uz`(;GR1HXBt|CKZ9Sj#0S zw{m8~#YB63xQ1WbPnn?YyfJxI{+iv7dqyx zh`+TcK~KIFik@zr{&^|5ePnFJ|9;lKr^GxZ*u}i3qnDvsj|PIwKdA)pyEPz{_;tVb zxkdRX>!w4E?$Jdc9ryad*xUE-gU82pbsYxPup)#%ygTh;bDd8Qe5|U9j$j8c3V4Dc za$2`nlIt@CyHohNtI{fv$u5N zEVRx@VP@ngN`@}Zo9Nh%m$X7LzBaqU*$`q}yOh{c;LJyDm7gDb?_h(gbBIQ`#BP_r zit1(jz^_$_t;H9u)=h|FC$}3g6I8>EF1uu&;XK*N-ah9icVFpQ-$gCfQ6YaQxY_fr zax0Ius2`;zjX^4U(T0#nrfqZXI-B&eLSBD`2RP4(!e+l8JKp%-Svhr%TGT_{?-ra9 z)Xh~-0@+#Y8dB2c)&1E<$o_t&yz|(+{C?ixY)rKSbV!u_?s+)DT8mg*T%1eCqHD85 z#=L5-a{G)>-B3! zBy_)le|-2(kmpl*C-gLaQ+2Wvta)S9X~SBLC9ZYBy-ARS&{Kjrw3@}HQbH%obcEKQUN*O2^~OIWZpEMuE`)bSylg@c&f zz?R>_Vzv0$kJqsYYWICYeCu>^g`Zu)+*^JV`NzX2Bp05=_kst(ywXO2xR%9*3AW=) zVszyF-{dB0TTP_A<4a&a0U3;$%qc*2dDtl}@a77lw?60UYiF+e>hYAQ=z__Q)Z~sVjJLTe+~Mo zD$IY&x52u{fq(kP1(TMJZTa^j$d6@lBM0{Hbt4Srf5lfG#w=li^QtmG&gqhoB=Ps* zAJG35r2kJ1>i_cUwwR-w|6u|C-!pUnmqGsj1N&#={;%56cZ01FMD3{~(V_Xts7rg8 zLD8{Ge+Zp|7`wM#i53s4HW}q9IXe6kjf%!clgPMh{Oy~h(c!b_=#;)`_phM_TVGQN zWM@q4r!f4wgyuvrZF1$GH5@?y`{}OR-m+0MPQu0J<~K~V9y!JY2}wsY?=9)!*7xBm zTBD%-6W_}4<3=zmaG*K{;0F1&Y(+i^032jl8Ob>o!J9`mKrZlDjn86O<1Xc6dwjoS z;VU5wSiL3^gC>Y*vZ!-HR8IP7-=)uQRBN%H?6Y1e6}C8JV^qMz6W5RuH^x9g^)32L zh){dEv#UYq?_bV0s_FK6=#g}+1yYQWl0w#$ge1T#_KAsB05c+kS?2cH)6$csVGokn zWR%;f%rDXv$RuohtZw=y3!pC%1UZeRjqQp`>&UB){?cOaK+PHPNKdSh;G35iG)FsG zo0hbpL07(w4nNKB`Pa}8PQ(SjebK&czq3Ps1$CU0JQ*L0L$&Pd6@UcM-;wr62;v9e0+^s5XC91NY; zh0-K+pND!#G4Uv`)xUZoO`_qMDn5I-M`MqR`vlQgWI*mmM#SjH;`QZ+)bi13{i|J>4{M@)vWP`a`whGyS)<%%!GpkiJw>WfR9=gyvJM;}z``H9Lo z1)p5~%KiSWX-Sh=SQ=>ENfmSyxeWgGRrd6EzMSIHI0;JR`n9X(kX+_=#v*^qwClkF#Wb zh7*AP_(EOV!KPY~z^ zHa$~^)2F`VhsW-=g4Dgscn|^GdM&bl7-r9~-Zc8O_5k-OCZcJJ<#=k5rL zs)*cNTcNeB*P`s~xAIek603qAP8VlX;S+;Fjbh z)nkgZBVH)-%r^@^e=-nwRQVukn#z`k#Msk)gPbKKi{c{F1~u%}cJ1fSSI^`o&{@jd z&Nk#@&CnSr2Il`PW>$P6v#z8H>idL;XB0KIAPa#b`&9w{2P8u&Ddn9~)}CS(d3kfZ zygloMUWLaW@(d(nAO8A`++0CHzv7E}?s<0*P9<63Zjq+aOu0FG-&`i55Jc%sob_@j zz~1rRew-zENlBFD6Hq>iQ{(7UP>Ao@$YsXx*e`(l>*j8=Ss6oPG2!DmamKK}ySaBu zcdq)8P19!J4~vBd5*f}9Dm^)oQdU7xK|OW4#vPm9{4mgJBR6yqYW;ds*yes_T58 zyw2_ieV8aC*7X}gbU+gABQIL67Pl7_+VjBi&i(n>J823h(gJ=t{ZG|E5zu!(^J!fU zbGtH-#(--tL|trR-f8M^)eOPIcG1NXa`KxjDbI|Y`hF2yp%U0S0_iqM9RpB z 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 diff --git a/bigbluebutton-tests/playwright/options/options.spec.js-snapshots/moderator-page-font-size-Chromium-linux.png b/bigbluebutton-tests/playwright/options/options.spec.js-snapshots/moderator-page-font-size-Chromium-linux.png index 131228ea8eb6d946a901913ca4b49aa26c84c957..3419c66c927723681c7a96c27c8564e30eca82be 100644 GIT binary patch literal 195919 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 z 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ⅈ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%0AFo`Rg8cVtkq&v|719p3{6lyV5*l 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 U#UJ;x2bf|!&;S4c literal 125651 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 AN&o-= diff --git a/bigbluebutton-tests/playwright/polling/poll.js b/bigbluebutton-tests/playwright/polling/poll.js index 2cce8f2272..c753c1da99 100644 --- a/bigbluebutton-tests/playwright/polling/poll.js +++ b/bigbluebutton-tests/playwright/polling/poll.js @@ -139,6 +139,8 @@ class Polling extends MultiUsers { await this.modPage.type(e.pollQuestionArea, 'Test'); await this.modPage.waitAndClick(e.addPollItem); await this.modPage.type(e.pollOptionItem, 'test1'); + await this.modPage.waitAndClick(e.addPollItem); + await this.modPage.type(e.pollOptionItem2, 'test2'); await this.modPage.waitAndClick(e.startPoll); await this.userPage.hasElement(e.pollingContainer); From f3a18e250c7aac96c69e318deca3d24f57212801 Mon Sep 17 00:00:00 2001 From: Tainan Felipe Date: Wed, 24 Jan 2024 16:55:13 -0300 Subject: [PATCH 0240/1039] Fix: TS compile errors --- .../poll/poll-graphql/components/LiveResult.tsx | 17 ++++++++--------- .../poll-graphql/components/ResponseChoices.tsx | 1 - .../poll-graphql/components/ResponseTypes.tsx | 1 - .../ui/components/poll/poll-graphql/styles.ts | 14 ++++++++++++-- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/LiveResult.tsx b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/LiveResult.tsx index ff6a86c541..bada830427 100644 --- a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/LiveResult.tsx +++ b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/LiveResult.tsx @@ -6,7 +6,12 @@ import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, } from 'recharts'; import Styled from '../styles'; -import { ResponseInfo, UserInfo, getCurrentPollData, getCurrentPollDataResponse } from '../queries'; +import { + ResponseInfo, + UserInfo, + getCurrentPollData, + getCurrentPollDataResponse, +} from '../queries'; import logger from '/imports/startup/client/logger'; import Settings from '/imports/ui/services/settings'; import { POLL_CANCEL, POLL_PUBLISH_RESULT } from '../mutation'; @@ -53,23 +58,23 @@ const intlMessages = defineMessages({ interface LiveResultProps { questionText: string; responses: Array; - isSecret: boolean; usersCount: number; numberOfAnswerCount: number; animations: boolean; pollId: string; users: Array; + isSecret: boolean; } const LiveResult: React.FC = ({ questionText, responses, - isSecret, usersCount, numberOfAnswerCount, animations, pollId, users, + isSecret, }) => { const intl = useIntl(); const [pollPublishResult] = useMutation(POLL_PUBLISH_RESULT); @@ -205,11 +210,8 @@ const LiveResultContainer: React.FC = () => { const currentPoll = currentPollData.poll[0]; const isSecret = currentPoll.secret; const { - multipleResponses, questionText, responses, - secret, - published, pollId, users, } = currentPoll; @@ -219,11 +221,8 @@ const LiveResultContainer: React.FC = () => { return ( ` resize: none; &:focus { @@ -135,6 +139,7 @@ const ResponseType = styled.div` } `; +// @ts-ignore - Button is a JS Component const PollConfigButton = styled(Button)` border: solid ${colorGrayLight} 1px; min-height: ${pollInputHeight}; @@ -181,6 +186,7 @@ const PollCheckbox = styled.div` margin-bottom: ${pollMdMargin}; `; +// @ts-ignore - Button is a JS Component const AddItemButton = styled(Button)` top: 1px; position: relative; @@ -244,6 +250,7 @@ const Toggle = styled.label` align-items: center; `; +// @ts-ignore - Button is a JS Component const StartPollBtn = styled(Button)` position: relative; width: 100%; @@ -265,6 +272,7 @@ const NoSlidePanelContainer = styled.div` text-align: center; `; +// @ts-ignore - Button is a JS Component const PollButton = styled(Button)` margin-top: ${smPaddingY}; margin-bottom: ${smPaddingY}; @@ -533,6 +541,7 @@ const ButtonsActions = styled.div` justify-content: space-between; `; +// @ts-ignore - Button is a JS Component const PublishButton = styled(Button)` width: 48%; overflow-wrap: break-word; @@ -541,6 +550,7 @@ const PublishButton = styled(Button)` const CancelButton = styled(PublishButton)``; +// @ts-ignore - Button is a JS Component const LiveResultButton = styled(Button)` width: 100%; margin-top: ${smPaddingY}; From c1a2827814adfc2ef8e9110fcc8e3a79ae568863 Mon Sep 17 00:00:00 2001 From: Tainan Felipe Date: Wed, 24 Jan 2024 17:03:28 -0300 Subject: [PATCH 0241/1039] Fix: Add disabled to publish button --- .../poll/poll-graphql/components/LiveResult.tsx | 1 + .../imports/ui/components/poll/poll-graphql/mutation.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/LiveResult.tsx b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/LiveResult.tsx index bada830427..ab0f278900 100644 --- a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/LiveResult.tsx +++ b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/components/LiveResult.tsx @@ -129,6 +129,7 @@ const LiveResult: React.FC = ({ publishPoll(pollId); stopPoll(); }} + disabled={numberOfAnswerCount <= 0} label={intl.formatMessage(intlMessages.publishLabel)} data-test="publishPollingLabel" color="primary" diff --git a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/mutation.ts b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/mutation.ts index b1de07793f..aaa05a0541 100644 --- a/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/mutation.ts +++ b/bigbluebutton-html5/imports/ui/components/poll/poll-graphql/mutation.ts @@ -33,7 +33,14 @@ export const POLL_CANCEL = gql` `; export const POLL_CREATE = gql` - mutation PollCreate($pollType: String!, $pollId: String!, $secretPoll: Boolean!, $question: String!, $isMultipleResponse: Boolean!, $answers: [String]!) { + mutation PollCreate( + $pollType: String!, + $pollId: String!, + $secretPoll: Boolean!, + $question: String!, + $isMultipleResponse: Boolean!, + $answers: [String]! + ) { pollCreate( pollType: $pollType, pollId: $pollId, From 4950470ff0189291125c13569e23e4a6cab96740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Thu, 25 Jan 2024 11:37:14 -0300 Subject: [PATCH 0242/1039] fix(chat): properly restore text input focus after a message is sent --- .../chat-message-form/component.tsx | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/component.tsx index e5f3411f9a..089701cb1e 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/component.tsx @@ -134,6 +134,7 @@ const ChatMessageForm: React.FC = ({ const [error, setError] = React.useState(null); const [message, setMessage] = React.useState(''); const [showEmojiPicker, setShowEmojiPicker] = React.useState(false); + const [isTextAreaFocused, setIsTextAreaFocused] = React.useState(false); const textAreaRef: RefObject = useRef(null); const { isMobile } = deviceInfo; const prevChatId = usePreviousValue(chatId); @@ -148,6 +149,10 @@ const ChatMessageForm: React.FC = ({ const [chatSetTyping] = useMutation(CHAT_SET_TYPING); + const [chatSendMessage, { + loading: chatSendMessageLoading, error: chatSendMessageError, + }] = useMutation(CHAT_SEND_MESSAGE); + const handleUserTyping = throttle( (hasError?: boolean) => { if (hasError || !ENABLE_TYPING_INDICATOR) return; @@ -196,6 +201,17 @@ const ChatMessageForm: React.FC = ({ setMessageHint(); }, [connected, locked, partnerIsLoggedOut]); + useEffect(() => { + const shouldRestoreFocus = textAreaRef.current + && !chatSendMessageLoading + && isTextAreaFocused + && document.activeElement !== textAreaRef.current.textarea; + + if (shouldRestoreFocus) { + textAreaRef.current.textarea.focus(); + } + }, [chatSendMessageLoading, textAreaRef.current]); + const setMessageHint = () => { let chatDisabledHint = null; @@ -258,10 +274,6 @@ const ChatMessageForm: React.FC = ({ const renderForm = () => { const formRef = useRef(null); - const [chatSendMessage, { - loading: chatSendMessageLoading, error: chatSendMessageError, - }] = useMutation(CHAT_SEND_MESSAGE); - const handleSubmit = (e: React.FormEvent | React.KeyboardEvent | Event) => { e.preventDefault(); @@ -295,10 +307,6 @@ const ChatMessageForm: React.FC = ({ setShowEmojiPicker(false); const sentMessageEvent = new CustomEvent(ChatEvents.SENT_MESSAGE); window.dispatchEvent(sentMessageEvent); - - setTimeout(() => { - textAreaRef.current?.textarea.focus(); - }, 100); }; const handleMessageKeyDown = (e: React.KeyboardEvent) => { @@ -364,9 +372,11 @@ const ChatMessageForm: React.FC = ({ value={message} onFocus={() => { window.dispatchEvent(new CustomEvent(PluginSdk.ChatFormEventsNames.CHAT_INPUT_FOCUSED)); + setIsTextAreaFocused(true); }} onBlur={() => { window.dispatchEvent(new CustomEvent(PluginSdk.ChatFormEventsNames.CHAT_INPUT_UNFOCUSED)); + setIsTextAreaFocused(false); }} onChange={handleMessageChange} onKeyDown={handleMessageKeyDown} From 968ad6961c6d908d681f6e21d2cccca23e6a3a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= Date: Thu, 25 Jan 2024 12:00:09 -0300 Subject: [PATCH 0243/1039] Client: System message style --- .../page/chat-message/component.tsx | 40 ++++++++++--------- .../message-content/text-content/styles.ts | 8 +--- .../page/chat-message/styles.ts | 6 +-- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx index 03fd6979e7..c385be57ed 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx @@ -95,10 +95,17 @@ const ChatMesssage: React.FC = ({ || lastSenderPreviousPage) === message?.user?.userId; const isSystemSender = message.messageType === ChatMessageType.BREAKOUT_ROOM; const dateTime = new Date(message?.createdAt); + + const msgTime = dateTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }); + const clearMessage = `${intl.formatMessage(intlMessages.chatClear)} at ${msgTime}`; + const { away } = JSON.parse(message.messageMetadata); + const awayMessage = (away) + ? `${msgTime} ${message.senderName} ${intl.formatMessage(intlMessages.userAway)}` + : `${msgTime} ${message.senderName} ${intl.formatMessage(intlMessages.userNotAway)}`; const messageContent: { name: string, - color: string, - isModerator: boolean, + color?: string, + isModerator?: boolean, component: React.ReactElement, } = useMemo(() => { switch (message.messageType) { @@ -123,12 +130,10 @@ const ChatMesssage: React.FC = ({ case ChatMessageType.CHAT_CLEAR: return { name: intl.formatMessage(intlMessages.systemLabel), - color: '#0F70D7', - isModerator: true, component: ( ), @@ -148,15 +153,12 @@ const ChatMesssage: React.FC = ({ ), }; case ChatMessageType.USER_AWAY_STATUS_MSG: { - const { away } = JSON.parse(message.messageMetadata); return { - name: message.senderName, - color: '#0F70D7', - isModerator: true, + name: '', component: ( ), @@ -191,12 +193,14 @@ const ChatMesssage: React.FC = ({ )} - + {!ChatMessageType.USER_AWAY_STATUS_MSG ? ( + + ) : null} {messageContent.component} diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/text-content/styles.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/text-content/styles.ts index 1f1562e0cb..b953e77bd0 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/text-content/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/text-content/styles.ts @@ -2,12 +2,8 @@ import styled from 'styled-components'; import { systemMessageBackgroundColor, systemMessageBorderColor, - systemMessageFontColor, colorText, } from '/imports/ui/stylesheets/styled-components/palette'; -import { - borderRadius, -} from '/imports/ui/stylesheets/styled-components/general'; import { fontSizeBase, btnFontWeight } from '/imports/ui/stylesheets/styled-components/typography'; interface ChatMessageProps { @@ -25,10 +21,10 @@ export const ChatMessage = styled.div` ${({ systemMsg }) => systemMsg && ` background: ${systemMessageBackgroundColor}; border: 1px solid ${systemMessageBorderColor}; - border-radius: ${borderRadius}; + border-radius: 1rem; font-weight: ${btnFontWeight}; padding: ${fontSizeBase}; - color: ${systemMessageFontColor}; + text-color: #1f252b; margin-top: 0; margin-bottom: 0; overflow-wrap: break-word; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/styles.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/styles.ts index 812538236a..3294e4dfdb 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/styles.ts @@ -25,9 +25,9 @@ interface ChatContentProps { } interface ChatAvatarProps { - avatar: string; - color: string; - moderator: boolean; + avatar?: string; + color?: string; + moderator?: boolean; emoji?: string; } From b9e51e3163f68c89bd2f0daac5e51e5ccfda612e Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Thu, 25 Jan 2024 12:27:53 -0300 Subject: [PATCH 0244/1039] Introduce networkRttInMs and applicationRttInMs --- bbb-graphql-server/bbb_schema.sql | 58 +++++++++++-------- .../public_v_user_connectionStatus.yaml | 4 +- .../connection-status/component.jsx | 41 +++++++++---- .../connection-status/mutations.jsx | 7 ++- .../components/connection-status/queries.jsx | 1 - 5 files changed, 73 insertions(+), 38 deletions(-) diff --git a/bbb-graphql-server/bbb_schema.sql b/bbb-graphql-server/bbb_schema.sql index 4c20e85869..ed9832296d 100644 --- a/bbb-graphql-server/bbb_schema.sql +++ b/bbb-graphql-server/bbb_schema.sql @@ -583,7 +583,8 @@ CREATE TABLE "user_connectionStatus" ( "meetingId" varchar(100) REFERENCES "meeting"("meetingId") ON DELETE CASCADE, "connectionAliveAt" timestamp with time zone, "userClientResponseAt" timestamp with time zone, - "rttInMs" numeric, + "networkRttInMs" numeric, + "applicationRttInMs" numeric, "status" varchar(25), "statusUpdatedAt" timestamp with time zone ); @@ -593,7 +594,7 @@ create view "v_user_connectionStatus" as select * from "user_connectionStatus"; --CREATE TABLE "user_connectionStatusHistory" ( -- "userId" varchar(50) REFERENCES "user"("userId") ON DELETE CASCADE, --- "rttInMs" numeric, +-- "applicationRttInMs" numeric, -- "status" varchar(25), -- "statusUpdatedAt" timestamp with time zone --); @@ -601,7 +602,8 @@ create view "v_user_connectionStatus" as select * from "user_connectionStatus"; -- "userId" varchar(50) REFERENCES "user"("userId") ON DELETE CASCADE, -- "status" varchar(25), -- "totalOfOccurrences" integer, --- "higherRttInMs" numeric, +-- "highestNetworkRttInMs" numeric, +-- "highestApplicationRttInMs" numeric, -- "statusInsertedAt" timestamp with time zone, -- "statusUpdatedAt" timestamp with time zone, -- CONSTRAINT "user_connectionStatusHistory_pkey" PRIMARY KEY ("userId","status") @@ -613,9 +615,12 @@ CREATE TABLE "user_connectionStatusMetrics" ( "occurrencesCount" integer, "firstOccurrenceAt" timestamp with time zone, "lastOccurrenceAt" timestamp with time zone, - "lowestRttInMs" numeric, - "highestRttInMs" numeric, - "lastRttInMs" numeric, + "lowestNetworkRttInMs" numeric, + "highestNetworkRttInMs" numeric, + "lastNetworkRttInMs" numeric, + "lowestApplicationRttInMs" numeric, + "highestApplicationRttInMs" numeric, + "lastApplicationRttInMs" numeric, CONSTRAINT "user_connectionStatusMetrics_pkey" PRIMARY KEY ("userId","status") ); @@ -624,31 +629,38 @@ create index "idx_user_connectionStatusMetrics_userId" on "user_connectionStatus --This function populate rtt, status and the table user_connectionStatusMetrics CREATE OR REPLACE FUNCTION "update_user_connectionStatus_trigger_func"() RETURNS TRIGGER AS $$ DECLARE - "newRttInMs" numeric; + "newApplicationRttInMs" numeric; "newStatus" varchar(25); BEGIN IF NEW."connectionAliveAt" IS NULL OR NEW."userClientResponseAt" IS NULL THEN RETURN NEW; END IF; - "newRttInMs" := (EXTRACT(EPOCH FROM (NEW."userClientResponseAt" - NEW."connectionAliveAt")) * 1000); - "newStatus" := CASE WHEN COALESCE("newRttInMs",0) > 2000 THEN 'critical' - WHEN COALESCE("newRttInMs",0) > 1000 THEN 'danger' - WHEN COALESCE("newRttInMs",0) > 500 THEN 'warning' + "newApplicationRttInMs" := (EXTRACT(EPOCH FROM (NEW."userClientResponseAt" - NEW."connectionAliveAt")) * 1000); + "newStatus" := CASE WHEN COALESCE(NEW."networkRttInMs",0) > 2000 THEN 'critical' + WHEN COALESCE(NEW."networkRttInMs",0) > 1000 THEN 'danger' + WHEN COALESCE(NEW."networkRttInMs",0) > 500 THEN 'warning' ELSE 'normal' END; --Update table user_connectionStatusMetrics WITH upsert AS (UPDATE "user_connectionStatusMetrics" SET "occurrencesCount" = "user_connectionStatusMetrics"."occurrencesCount" + 1, - "highestRttInMs" = GREATEST("user_connectionStatusMetrics"."highestRttInMs","newRttInMs"), - "lowestRttInMs" = LEAST("user_connectionStatusMetrics"."lowestRttInMs","newRttInMs"), - "lastRttInMs" = "newRttInMs", + "highestApplicationRttInMs" = GREATEST("user_connectionStatusMetrics"."highestApplicationRttInMs","newApplicationRttInMs"), + "lowestApplicationRttInMs" = LEAST("user_connectionStatusMetrics"."lowestApplicationRttInMs","newApplicationRttInMs"), + "lastApplicationRttInMs" = "newApplicationRttInMs", + "highestNetworkRttInMs" = GREATEST("user_connectionStatusMetrics"."highestNetworkRttInMs",NEW."networkRttInMs"), + "lowestNetworkRttInMs" = LEAST("user_connectionStatusMetrics"."lowestNetworkRttInMs",NEW."networkRttInMs"), + "lastNetworkRttInMs" = NEW."networkRttInMs", "lastOccurrenceAt" = current_timestamp WHERE "userId"=NEW."userId" AND "status"= "newStatus" RETURNING *) - INSERT INTO "user_connectionStatusMetrics"("userId","status","occurrencesCount", "highestRttInMs", "lowestRttInMs", "lastRttInMs", "firstOccurrenceAt") - SELECT NEW."userId", "newStatus", 1, "newRttInMs", "newRttInMs", "newRttInMs", current_timestamp + INSERT INTO "user_connectionStatusMetrics"("userId","status","occurrencesCount", "firstOccurrenceAt", + "highestApplicationRttInMs", "lowestApplicationRttInMs", "lastApplicationRttInMs", + "highestNetworkRttInMs", "lowestNetworkRttInMs", "lastNetworkRttInMs") + SELECT NEW."userId", "newStatus", 1, current_timestamp, + "newApplicationRttInMs", "newApplicationRttInMs", "newApplicationRttInMs", + NEW."networkRttInMs", NEW."networkRttInMs", NEW."networkRttInMs" WHERE NOT EXISTS (SELECT * FROM upsert); - --Update rttInMs, status, statusUpdatedAt in user_connectionStatus + --Update networkRttInMs, applicationRttInMs, status, statusUpdatedAt in user_connectionStatus UPDATE "user_connectionStatus" - SET "rttInMs" = "newRttInMs", + SET "applicationRttInMs" = "newApplicationRttInMs", "status" = "newStatus", "statusUpdatedAt" = now() WHERE "userId" = NEW."userId"; @@ -659,12 +671,12 @@ $$ LANGUAGE plpgsql; CREATE TRIGGER "update_user_connectionStatus_trigger" AFTER UPDATE OF "userClientResponseAt" ON "user_connectionStatus" FOR EACH ROW EXECUTE FUNCTION "update_user_connectionStatus_trigger_func"(); ---This function clear userClientResponseAt and rttInMs when connectionAliveAt is updated +--This function clear userClientResponseAt and applicationRttInMs when connectionAliveAt is updated CREATE OR REPLACE FUNCTION "update_user_connectionStatus_connectionAliveAt_trigger_func"() RETURNS TRIGGER AS $$ BEGIN IF NEW."connectionAliveAt" <> OLD."connectionAliveAt" THEN NEW."userClientResponseAt" := NULL; - NEW."rttInMs" := NULL; + NEW."applicationRttInMs" := NULL; END IF; RETURN NEW; END; @@ -678,8 +690,8 @@ CREATE OR REPLACE VIEW "v_user_connectionStatusReport" AS SELECT u."meetingId", u."userId", max(cs."connectionAliveAt") AS "connectionAliveAt", max(cs."status") AS "currentStatus", ---COALESCE(max(cs."rttInMs"),(EXTRACT(EPOCH FROM (current_timestamp - max(cs."connectionAliveAt"))) * 1000)) AS "rttInMs", -CASE WHEN max(cs."connectionAliveAt") < current_timestamp - INTERVAL '10 seconds' THEN TRUE ELSE FALSE END AS "clientNotResponding", +--COALESCE(max(cs."applicationRttInMs"),(EXTRACT(EPOCH FROM (current_timestamp - max(cs."connectionAliveAt"))) * 1000)) AS "applicationRttInMs", +CASE WHEN max(cs."connectionAliveAt") < current_timestamp - INTERVAL '12 seconds' THEN TRUE ELSE FALSE END AS "clientNotResponding", (array_agg(csm."status" ORDER BY csm."lastOccurrenceAt" DESC))[1] as "lastUnstableStatus", max(csm."lastOccurrenceAt") AS "lastUnstableStatusAt" FROM "user" u @@ -702,7 +714,7 @@ CREATE INDEX "idx_user_graphqlConnectionsessionToken" ON "user_graphqlConnection ---ALTER TABLE "user_connectionStatus" ADD COLUMN "rttInMs" NUMERIC GENERATED ALWAYS AS +--ALTER TABLE "user_connectionStatus" ADD COLUMN "applicationRttInMs" NUMERIC GENERATED ALWAYS AS --(CASE WHEN "connectionAliveAt" IS NULL OR "userClientResponseAt" IS NULL THEN NULL --ELSE EXTRACT(EPOCH FROM ("userClientResponseAt" - "connectionAliveAt")) * 1000 --END) STORED; diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_connectionStatus.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_connectionStatus.yaml index bf3a6652bd..9e4dada33f 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_connectionStatus.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_connectionStatus.yaml @@ -22,7 +22,8 @@ select_permissions: columns: - connectionAliveAt - meetingId - - rttInMs + - applicationRttInMs + - networkRttInMs - status - statusUpdatedAt - userClientResponseAt @@ -39,6 +40,7 @@ update_permissions: columns: - connectionAliveAt - userClientResponseAt + - networkRttInMs filter: _and: - meetingId: diff --git a/bigbluebutton-html5/imports/ui/components/connection-status/component.jsx b/bigbluebutton-html5/imports/ui/components/connection-status/component.jsx index 622b5e2c6f..1068993244 100755 --- a/bigbluebutton-html5/imports/ui/components/connection-status/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/connection-status/component.jsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useMutation, useSubscription } from '@apollo/client'; import { CONNECTION_STATUS_SUBSCRIPTION } from './queries'; import { UPDATE_CONNECTION_ALIVE_AT, UPDATE_USER_CLIENT_RESPONSE_AT } from './mutations'; @@ -6,20 +6,36 @@ import { UPDATE_CONNECTION_ALIVE_AT, UPDATE_USER_CLIENT_RESPONSE_AT } from './mu const STATS_INTERVAL = Meteor.settings.public.stats.interval; const ConnectionStatus = () => { + const networkRttInMs = useRef(null); // Ref to store the current timeout + const lastStatusUpdatedAtReceived = useRef(null); // Ref to store the current timeout + const timeoutRef = useRef(null); + const [updateUserClientResponseAtToMeAsNow] = useMutation(UPDATE_USER_CLIENT_RESPONSE_AT); const handleUpdateUserClientResponseAt = () => { - updateUserClientResponseAtToMeAsNow(); + updateUserClientResponseAtToMeAsNow({ + variables: { + networkRttInMs: networkRttInMs.current, + }, + }); }; const [updateConnectionAliveAtToMeAsNow] = useMutation(UPDATE_CONNECTION_ALIVE_AT); const handleUpdateConnectionAliveAt = () => { - updateConnectionAliveAtToMeAsNow(); + const startTime = performance.now(); + updateConnectionAliveAtToMeAsNow().then(() => { + const endTime = performance.now(); + networkRttInMs.current = endTime - startTime; + }).finally(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } - setTimeout(() => { - handleUpdateConnectionAliveAt(); - }, STATS_INTERVAL); + timeoutRef.current = setTimeout(() => { + handleUpdateConnectionAliveAt(); + }, STATS_INTERVAL); + }); }; useEffect(() => { @@ -30,11 +46,14 @@ const ConnectionStatus = () => { if (!loading && !error && data) { data.user_connectionStatus.forEach((curr) => { - if (curr.userClientResponseAt == null) { - const delay = 500; - setTimeout(() => { - handleUpdateUserClientResponseAt(); - }, delay); + if (curr.connectionAliveAt != null + && curr.userClientResponseAt == null + && (curr.statusUpdatedAt == null + || curr.statusUpdatedAt !== lastStatusUpdatedAtReceived.current + ) + ) { + lastStatusUpdatedAtReceived.current = curr.statusUpdatedAt; + handleUpdateUserClientResponseAt(); } }); } diff --git a/bigbluebutton-html5/imports/ui/components/connection-status/mutations.jsx b/bigbluebutton-html5/imports/ui/components/connection-status/mutations.jsx index f382e04e4a..e8a5f6a72a 100644 --- a/bigbluebutton-html5/imports/ui/components/connection-status/mutations.jsx +++ b/bigbluebutton-html5/imports/ui/components/connection-status/mutations.jsx @@ -11,10 +11,13 @@ export const UPDATE_CONNECTION_ALIVE_AT = gql` }`; export const UPDATE_USER_CLIENT_RESPONSE_AT = gql` - mutation UpdateConnectionAliveAt($userId: String, $userClientResponseAt: timestamp) { + mutation UpdateConnectionClientResponse($networkRttInMs: numeric) { update_user_connectionStatus( where: {userClientResponseAt: {_is_null: true}} - _set: { userClientResponseAt: "now()" } + _set: { + userClientResponseAt: "now()", + networkRttInMs: $networkRttInMs + } ) { affected_rows } diff --git a/bigbluebutton-html5/imports/ui/components/connection-status/queries.jsx b/bigbluebutton-html5/imports/ui/components/connection-status/queries.jsx index 94193d3b52..ec72f53871 100644 --- a/bigbluebutton-html5/imports/ui/components/connection-status/queries.jsx +++ b/bigbluebutton-html5/imports/ui/components/connection-status/queries.jsx @@ -21,7 +21,6 @@ export const CONNECTION_STATUS_SUBSCRIPTION = gql`subscription { user_connectionStatus { connectionAliveAt userClientResponseAt - rttInMs status statusUpdatedAt } From 32d18be2686ac9e75bf71c544e335742d0bdd7c2 Mon Sep 17 00:00:00 2001 From: Gabriel Porfirio Date: Thu, 25 Jan 2024 13:49:04 -0300 Subject: [PATCH 0245/1039] rm flaky flag skip slide test --- .../playwright/presentation/presentation.spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bigbluebutton-tests/playwright/presentation/presentation.spec.js b/bigbluebutton-tests/playwright/presentation/presentation.spec.js index d206e39115..3bdb39e9c5 100644 --- a/bigbluebutton-tests/playwright/presentation/presentation.spec.js +++ b/bigbluebutton-tests/playwright/presentation/presentation.spec.js @@ -7,7 +7,7 @@ const customStyleAvoidUploadingNotifications = encodeCustomParams(`userdata-bbb_ test.describe.parallel('Presentation', () => { // https://docs.bigbluebutton.org/2.6/release-tests.html#navigation-automated - test('Skip slide @ci @flaky', async ({ browser, context, page }) => { + test('Skip slide @ci', async ({ browser, context, page }) => { const presentation = new Presentation(browser, context); await presentation.initPages(page); await presentation.skipSlide(); @@ -47,13 +47,13 @@ test.describe.parallel('Presentation', () => { await presentation.presentationFullscreen(); }); - test('Presentation snapshot @ci @flaky', async ({ browser, context, page }, testInfo) => { + test.only('Presentation snapshot @ci @flaky', async ({ browser, context, page }, testInfo) => { const presentation = new Presentation(browser, context); await presentation.initPages(page); await presentation.presentationSnapshot(testInfo); }); - test('Hide Presentation Toolbar @ci @flaky', async ({ browser, context, page }) => { + test.only('Hide Presentation Toolbar @ci @flaky', async ({ browser, context, page }) => { const presentation = new Presentation(browser, context); await presentation.initPages(page); await presentation.hidePresentationToolbar(); From a5e08b30122bd59d0c2339517ff659d24bdeec63 Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Thu, 25 Jan 2024 13:43:59 -0500 Subject: [PATCH 0246/1039] chore: bump axios (bbb-html5) --- bigbluebutton-html5/package-lock.json | 14 +++++++------- bigbluebutton-html5/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bigbluebutton-html5/package-lock.json b/bigbluebutton-html5/package-lock.json index d3a81e4834..00501f1ac0 100644 --- a/bigbluebutton-html5/package-lock.json +++ b/bigbluebutton-html5/package-lock.json @@ -3339,11 +3339,11 @@ "dev": true }, "axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.6.tgz", + "integrity": "sha512-XZLZDFfXKM9U/Y/B4nNynfCRUqNyVZ4sBC/n9GDRCkq9vd2mIvKjKKsbIh1WPmHmNbg6ND7cTBY3Y2+u1G3/2Q==", "requires": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -4918,9 +4918,9 @@ } }, "follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==" + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==" }, "for-each": { "version": "0.3.3", diff --git a/bigbluebutton-html5/package.json b/bigbluebutton-html5/package.json index 5a67275380..507b0f6046 100644 --- a/bigbluebutton-html5/package.json +++ b/bigbluebutton-html5/package.json @@ -45,7 +45,7 @@ "@types/ramda": "^0.29.2", "@types/react": "^18.2.18", "autoprefixer": "^10.4.4", - "axios": "^1.6.0", + "axios": "^1.6.4", "babel-runtime": "~6.26.0", "bigbluebutton-html-plugin-sdk": "0.0.32", "bowser": "^2.11.0", From 9eeaf1db2e8c84c099c38c5d0d9c2ed99d0ff2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= Date: Thu, 25 Jan 2024 16:21:51 -0300 Subject: [PATCH 0247/1039] Solving errors --- .../page/chat-message/component.tsx | 22 ++++++++++++------- .../page/chat-message/styles.ts | 6 ++--- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx index c385be57ed..2a104697d2 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx @@ -98,14 +98,11 @@ const ChatMesssage: React.FC = ({ const msgTime = dateTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }); const clearMessage = `${intl.formatMessage(intlMessages.chatClear)} at ${msgTime}`; - const { away } = JSON.parse(message.messageMetadata); - const awayMessage = (away) - ? `${msgTime} ${message.senderName} ${intl.formatMessage(intlMessages.userAway)}` - : `${msgTime} ${message.senderName} ${intl.formatMessage(intlMessages.userNotAway)}`; + const messageContent: { name: string, - color?: string, - isModerator?: boolean, + color: string, + isModerator: boolean, component: React.ReactElement, } = useMemo(() => { switch (message.messageType) { @@ -130,6 +127,9 @@ const ChatMesssage: React.FC = ({ case ChatMessageType.CHAT_CLEAR: return { name: intl.formatMessage(intlMessages.systemLabel), + color: '', + isModerator: false, + isSystemSender: true, component: ( = ({ ), }; case ChatMessageType.USER_AWAY_STATUS_MSG: { + const { away } = JSON.parse(message.messageMetadata); + const awayMessage = (away) + ? `${msgTime} ${message.senderName} ${intl.formatMessage(intlMessages.userAway)}` + : `${msgTime} ${message.senderName} ${intl.formatMessage(intlMessages.userNotAway)}`; return { - name: '', + name: message.senderName, + color: '', + isModerator: false, component: ( = ({ isOnline={message.user?.isOnline ?? true} dateTime={dateTime} /> - ) : null} + ) : null } {messageContent.component} diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/styles.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/styles.ts index 3294e4dfdb..812538236a 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/styles.ts @@ -25,9 +25,9 @@ interface ChatContentProps { } interface ChatAvatarProps { - avatar?: string; - color?: string; - moderator?: boolean; + avatar: string; + color: string; + moderator: boolean; emoji?: string; } From 3ef6e2254d710809ab2675af457c4b11dcf6f816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Thu, 25 Jan 2024 17:12:23 -0300 Subject: [PATCH 0248/1039] fix param --- .../presentation-download-dropdown/component.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/component.jsx index 12f2c34b2c..84cfbe2947 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/component.jsx @@ -103,7 +103,7 @@ class PresentationDownloadDropdown extends PureComponent { const downloadableExtension = downloadFileUri?.split('.').slice(-1)[0]; const originalFileExtension = name?.split('.').slice(-1)[0]; const changeDownloadOriginalOrConvertedPresentation = (enableDownload, fileStateType) => { - handleDownloadableChange(item, fileStateType, enableDownload); + handleDownloadableChange(item?.presentationId, fileStateType, enableDownload); if (enableDownload) { handleDownloadingOfPresentation(fileStateType); } From 5c8a597cfb2499cd89bb2290c7ec9ec841611fe7 Mon Sep 17 00:00:00 2001 From: Jan Kessler Date: Thu, 30 Nov 2023 19:24:14 +0100 Subject: [PATCH 0249/1039] fix away and raiseHands 'reactions' being hidden by avatar image --- .../user-list-participants/list-item/component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/list-item/component.tsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/list-item/component.tsx index 1b0d641694..71bd556d92 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/list-item/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/list-item/component.tsx @@ -151,7 +151,7 @@ const UserListItem: React.FC = ({ user, lockSettings }) => { const reactionsEnabled = isReactionsEnabled(); - const userAvatarFiltered = user.avatar; + const userAvatarFiltered = (user.raiseHand === true || user.away === true || ( user.reaction && user.reaction.reactionEmoji !== 'none')) ? '' : user.avatar; const emojiIcons = [ { From 1c62b972f4bc9033bd2dfb5c095f169962888dee Mon Sep 17 00:00:00 2001 From: srikantharika Date: Fri, 17 Nov 2023 23:56:35 +0530 Subject: [PATCH 0250/1039] Update install.md link path at line 336 path doesn't exist. modified with actual path. From 7b44d803046d1cb8c3ab643e13dfe435f014a099 Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Mon, 27 Nov 2023 09:16:42 -0500 Subject: [PATCH 0251/1039] Update links formatting --- docs/docs/administration/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/administration/install.md b/docs/docs/administration/install.md index 97e5c58928..8f4b013b47 100644 --- a/docs/docs/administration/install.md +++ b/docs/docs/administration/install.md @@ -334,7 +334,7 @@ You can upgrade by re-running the `bbb-install.sh` script again -- it will downl If you are upgrading BigBlueButton 2.6 or 2.7 we recommend you set up a new Ubuntu 22.04 server with BigBlueButton 3.0 and then [copy over your existing recordings from the old server](/administration/customize#transfer-published-recordings-from-another-server). -Make sure you read through the "what's new in 3.0" document https://docs.bigbluebutton.org/3.0/new and especifically https://docs.bigbluebutton.org/3.0/new#other-notable-changes +Make sure you read through the ["what's new in 3.0" document](https://docs.bigbluebutton.org/3.0/new) and specifically [the section covering notable changes](https://docs.bigbluebutton.org/3.0/new#other-notable-changes) ### Restart your server From 289c80c68879d6f72f4ec440d831736dbc0da60a Mon Sep 17 00:00:00 2001 From: Tim Bird Date: Tue, 3 Oct 2023 10:57:33 -0600 Subject: [PATCH 0252/1039] Update bbb-conf.md - fix typo in word 'easi' Change 'easi' to 'easy' --- docs/docs/administration/bbb-conf.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/administration/bbb-conf.md b/docs/docs/administration/bbb-conf.md index 2e533c0f70..6b8c9e8bb4 100644 --- a/docs/docs/administration/bbb-conf.md +++ b/docs/docs/administration/bbb-conf.md @@ -10,7 +10,7 @@ keywords: ## Introduction -`bbb-conf` is BigBlueButton's configuration tool. It makes it easi for you to modify parts of BigBlueButton's configuration, manage the BigBlueButton system (start/stop/reset), and troubleshoot potential problems with your setup. +`bbb-conf` is BigBlueButton's configuration tool. It makes it easy for you to modify parts of BigBlueButton's configuration, manage the BigBlueButton system (start/stop/reset), and troubleshoot potential problems with your setup. As a historical note, this tool was created early in the development of BigBlueButton. The core developers wrote this tool to quickly update BigBlueButton's configuration files for setup and testing. From ee412b7c09dfcb67e1dde7b679a07283e73c2e6c Mon Sep 17 00:00:00 2001 From: farhatahmad Date: Thu, 23 Nov 2023 14:22:11 -0500 Subject: [PATCH 0253/1039] Greenlight v3 more docs updates --- docs/docs/greenlight/v3/migration.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/docs/greenlight/v3/migration.md b/docs/docs/greenlight/v3/migration.md index a23a760cd6..18131aef89 100644 --- a/docs/docs/greenlight/v3/migration.md +++ b/docs/docs/greenlight/v3/migration.md @@ -60,7 +60,7 @@ In Greenlight v2 **.env** file, add the following variables: ![env_migration_endpoints.png](/img/greenlight/v3/migration/env_migration_endpoints.png) -## Migration Steps +### Understanding the Migrations **The migrations must be run in the following order: roles, users, rooms, settings.** @@ -80,7 +80,7 @@ However, a failed migration resource should not hinder the whole migration proce **If re-running the migration does not solve the issue, the error message should give you a clue of what went wrong.** -### Roles Migration +## Roles Migration The custom Roles and the corresponding Role Permissions will be migrated. @@ -96,13 +96,15 @@ sudo docker exec -it greenlight-v2 bundle exec rake migrations:roles **If you have an error, try re-running the migration task to resolve any failed resources migration.** **Also, make sure that the Greenlight v3 server is running and accessible through your network.** -### Users Migration +## Users Migration The Users will be migrated with their corresponding role. Important notes: - Both local and external users will be migrated. -#### Local Accounts +### Local Accounts +** If you only have external users (google, office365, LDAP, SAML, etc..), please skip to the next section.** + When migrating local accounts from GLv2 to GLv3, the password_digest field will be securely transferred from v2 to v3. This ensures that local customers can seamlessly sign in using the exact same password as in v2. To enable this, it's crucial that both GLv2 and GLv3 share the same value for the SECRET_KEY_BASE environment variable, which is set in the .env file. @@ -128,19 +130,17 @@ On your GLv3 machine, replace the `SECRET_KEY_BASE` in your .env file with the s Ensure that the `SECRET_KEY_BASE` values for GLv2, GLv3, and the `V3_SECRET_KEY_BASE` variable in GLv2's `.env` file are now synchronized. -#### Migrating Users +### Migrating Users **To migrate all of your v2 users to v3, run the following command:** ```bash sudo docker exec -it greenlight-v2 bundle exec rake migrations:users ``` -**To migrate only a portion of the users starting from *FIRST_USER_ID* to *LAST_USER_ID*, run this command instead:** - **If you have an error, try re-running the migration task to resolve any failed resources migration.** **Also, make sure that the Roles migration has been successful.** -### Rooms Migration +## Rooms Migration The Rooms will be migrated with their corresponding Room Settings. Also, the Shared Accesses will be migrated. Important notes: @@ -158,7 +158,7 @@ sudo docker exec -it greenlight-v2 bundle exec rake migrations:rooms **If you have an error, try re-running the migration task to resolve any failed resources migration.** **Also, make sure that the Users migration has been successful.** -### Settings Migration +## Settings Migration The Site Settings and the Rooms Configuration will be migrated. - The *Site Settings* are customisable settings related to the Greenlight application, such as the Brand colors, the Brand image, the Registration method, the Terms & Conditions. From e34c2d0c627acdbb72bc56e2d89c9a1e328134cb Mon Sep 17 00:00:00 2001 From: SilentFlameCR Date: Mon, 27 Nov 2023 16:56:31 -0500 Subject: [PATCH 0254/1039] updated greenlight docs to include user:set_admin_role rake task --- docs/docs/greenlight/v3/install.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/docs/greenlight/v3/install.md b/docs/docs/greenlight/v3/install.md index 380dba2b93..7169143b20 100644 --- a/docs/docs/greenlight/v3/install.md +++ b/docs/docs/greenlight/v3/install.md @@ -48,6 +48,12 @@ You can also run it without any arguments to create the default admin account, w docker exec -it greenlight-v3 bundle exec rake admin:create ``` +### Upgrading an existing account to an Admin Account + +You can do that by running the following command: +```bash +docker exec -it greenlight-v3 bundle exec rake user:set_admin_role['email'] +``` ## Installing on a Standalone Server ### Greenlight Install Script From ec0799883fe76452bc62368ca1e937a72d4ad9db Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Thu, 25 Jan 2024 15:59:26 -0500 Subject: [PATCH 0255/1039] fix: drop eslint upsetting space --- .../user-list-participants/list-item/component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/list-item/component.tsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/list-item/component.tsx index 71bd556d92..331d1ebd8d 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/list-item/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/list-item/component.tsx @@ -151,7 +151,7 @@ const UserListItem: React.FC = ({ user, lockSettings }) => { const reactionsEnabled = isReactionsEnabled(); - const userAvatarFiltered = (user.raiseHand === true || user.away === true || ( user.reaction && user.reaction.reactionEmoji !== 'none')) ? '' : user.avatar; + const userAvatarFiltered = (user.raiseHand === true || user.away === true || (user.reaction && user.reaction.reactionEmoji !== 'none')) ? '' : user.avatar; const emojiIcons = [ { From 5b9b877562657acb823c50c7b4bcf2d98126672e Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Thu, 25 Jan 2024 18:17:16 -0300 Subject: [PATCH 0256/1039] Client test code for new rtt calc --- bbb-graphql-client-test/src/Auth.js | 8 +-- .../src/UserConnectionStatus.js | 67 ++++++++++++++----- .../src/UserConnectionStatusReport.js | 2 +- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/bbb-graphql-client-test/src/Auth.js b/bbb-graphql-client-test/src/Auth.js index e5099f15b1..0c730623fa 100644 --- a/bbb-graphql-client-test/src/Auth.js +++ b/bbb-graphql-client-test/src/Auth.js @@ -139,11 +139,11 @@ export default function Auth() { You are online, welcome {curr.name} ({curr.userId}) - {/**/} - {/*
*/} + +
- {/**/} - {/*
*/} + +
diff --git a/bbb-graphql-client-test/src/UserConnectionStatus.js b/bbb-graphql-client-test/src/UserConnectionStatus.js index a949891764..0d1b43b0a9 100644 --- a/bbb-graphql-client-test/src/UserConnectionStatus.js +++ b/bbb-graphql-client-test/src/UserConnectionStatus.js @@ -1,8 +1,10 @@ import {gql, useMutation, useSubscription} from '@apollo/client'; -import React, {useEffect} from "react"; +import React, {useEffect, useState, useRef } from "react"; import {applyPatch} from "fast-json-patch"; export default function UserConnectionStatus() { + const networkRttInMs = useRef(null); // Ref to store the current timeout + const lastStatusUpdatedAtReceived = useRef(null); // Ref to store the current timeout //example specifying where and time (new Date().toISOString()) //but its not necessary @@ -18,13 +20,20 @@ export default function UserConnectionStatus() { // `); + const timeoutRef = useRef(null); // Ref to store the current timeout + + + //where is not necessary once user can update only its own status //Hasura accepts "now()" as value to timestamp fields const [updateUserClientResponseAtToMeAsNow] = useMutation(gql` - mutation UpdateConnectionAliveAt($userId: String, $userClientResponseAt: timestamp) { + mutation UpdateConnectionClientResponse($networkRttInMs: numeric) { update_user_connectionStatus( where: {userClientResponseAt: {_is_null: true}} - _set: { userClientResponseAt: "now()" } + _set: { + userClientResponseAt: "now()", + networkRttInMs: $networkRttInMs + } ) { affected_rows } @@ -32,7 +41,11 @@ export default function UserConnectionStatus() { `); const handleUpdateUserClientResponseAt = () => { - updateUserClientResponseAtToMeAsNow(); + updateUserClientResponseAtToMeAsNow({ + variables: { + networkRttInMs: networkRttInMs.current + }, + }); }; @@ -48,11 +61,25 @@ export default function UserConnectionStatus() { `); const handleUpdateConnectionAliveAt = () => { - updateConnectionAliveAtToMeAsNow(); + const startTime = performance.now(); - setTimeout(() => { + try { + updateConnectionAliveAtToMeAsNow().then(result => { + const endTime = performance.now(); + networkRttInMs.current = endTime - startTime; + + }); + } catch (error) { + console.error('Error performing mutation:', error); + } + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { handleUpdateConnectionAliveAt(); - }, 25000); + }, 5000); }; useEffect(() => { @@ -66,7 +93,8 @@ export default function UserConnectionStatus() { user_connectionStatus { connectionAliveAt userClientResponseAt - rttInMs + applicationRttInMs + networkRttInMs status statusUpdatedAt } @@ -83,7 +111,8 @@ export default function UserConnectionStatus() { {/*Id*/} connectionAliveAt userClientResponseAt - rttInMs + applicationRttInMs + networkRttInMs status statusUpdatedAt @@ -92,12 +121,17 @@ export default function UserConnectionStatus() { {data.user_connectionStatus.map((curr) => { // console.log('user_connectionStatus', curr); - if(curr.userClientResponseAt == null) { - // handleUpdateUserClientResponseAt(); - const delay = 500; - setTimeout(() => { - handleUpdateUserClientResponseAt(); - },delay); + console.log('curr.statusUpdatedAt',curr.statusUpdatedAt); + console.log('lastStatusUpdatedAtReceived.current',lastStatusUpdatedAtReceived.current); + + if(curr.userClientResponseAt == null + && (curr.statusUpdatedAt == null || curr.statusUpdatedAt !== lastStatusUpdatedAtReceived.current)) { + + + + lastStatusUpdatedAtReceived.current = curr.statusUpdatedAt; + // setLastStatusUpdatedAtReceived(curr.statusUpdatedAt); + handleUpdateUserClientResponseAt(); } return ( @@ -106,7 +140,8 @@ export default function UserConnectionStatus() { {curr.userClientResponseAt} - {curr.rttInMs} + {curr.applicationRttInMs} + {curr.networkRttInMs} {curr.status} {curr.statusUpdatedAt} diff --git a/bbb-graphql-client-test/src/UserConnectionStatusReport.js b/bbb-graphql-client-test/src/UserConnectionStatusReport.js index 1b4157904c..b7bfbd0292 100644 --- a/bbb-graphql-client-test/src/UserConnectionStatusReport.js +++ b/bbb-graphql-client-test/src/UserConnectionStatusReport.js @@ -33,7 +33,7 @@ export default function UserConnectionStatusReport() { {data.user_connectionStatusReport.map((curr) => { - console.log('user_connectionStatusReport', curr); + //console.log('user_connectionStatusReport', curr); return ( {curr.user.name} From 6dcf99bd96d0605a85691d5a424f70a7c78d59aa Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Thu, 25 Jan 2024 16:23:37 -0500 Subject: [PATCH 0257/1039] docs: Capitalization in Support section --- docs/docusaurus.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index bb1b698515..02befcecd9 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -65,7 +65,7 @@ const config = { {to: '/administration/install', label: 'Administration', position: 'left'}, {to: '/greenlight/v3/install', label: 'Greenlight', position: 'left'}, {to: '/new-features', label: 'New Features', position: 'left'}, - {to: '/support/getting-help', label: 'support', position: 'left'}, + {to: '/support/getting-help', label: 'Support', position: 'left'}, { type: 'docsVersionDropdown', position: 'right', From e1b6baa4390e909405b8dd8d773dad6870dd31c4 Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Thu, 25 Jan 2024 16:31:02 -0500 Subject: [PATCH 0258/1039] docs: Force trailingSlash Allow to customize the presence/absence of a trailing slash at the end of URLs/links, and how static HTML files are generated --- docs/docusaurus.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 867997ca54..6d4f9a9e89 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -13,6 +13,7 @@ const config = { onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', favicon: 'img/favicon.ico', + trailingSlash: true, // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. From 05ddcb659da9c96818d3770ddc08aa91eb782aed Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Thu, 25 Jan 2024 18:50:21 -0300 Subject: [PATCH 0259/1039] Prevent middlware from retransmiting Mutations --- .../internal/hascli/conn/writer/writer.go | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go index 9d7ea23837..d6fa628f2e 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go +++ b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go @@ -96,20 +96,23 @@ RangeLoop: jsonPatchSupported = true } - browserConnection.ActiveSubscriptionsMutex.Lock() - browserConnection.ActiveSubscriptions[queryId] = common.GraphQlSubscription{ - Id: queryId, - Message: fromBrowserMessageAsMap, - OperationName: operationName, - StreamCursorField: streamCursorField, - StreamCursorVariableName: streamCursorVariableName, - StreamCursorCurrValue: streamCursorInitialValue, - LastSeenOnHasuraConnetion: hc.Id, - JsonPatchSupported: jsonPatchSupported, - Type: messageType, + //Not storing Mutations because they will not be retransmitted in case of reconnection + if messageType != common.Mutation { + browserConnection.ActiveSubscriptionsMutex.Lock() + browserConnection.ActiveSubscriptions[queryId] = common.GraphQlSubscription{ + Id: queryId, + Message: fromBrowserMessageAsMap, + OperationName: operationName, + StreamCursorField: streamCursorField, + StreamCursorVariableName: streamCursorVariableName, + StreamCursorCurrValue: streamCursorInitialValue, + LastSeenOnHasuraConnetion: hc.Id, + JsonPatchSupported: jsonPatchSupported, + Type: messageType, + } + // log.Tracef("Current queries: %v", browserConnection.ActiveSubscriptions) + browserConnection.ActiveSubscriptionsMutex.Unlock() } - // log.Tracef("Current queries: %v", browserConnection.ActiveSubscriptions) - browserConnection.ActiveSubscriptionsMutex.Unlock() } if fromBrowserMessageAsMap["type"] == "stop" { From 9530cbdd9eed11378869eb386a02eac7722602f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Fri, 26 Jan 2024 09:55:55 -0300 Subject: [PATCH 0260/1039] fix export presentation, add new action --- .../src/actions/presentationExport.ts | 27 +++++++++++++++++++ .../actions/presentationSetDownloadable.ts | 1 - bbb-graphql-server/metadata/actions.graphql | 7 +++++ bbb-graphql-server/metadata/actions.yaml | 7 +++++ .../ui/components/presentation/mutations.jsx | 12 +++++++++ .../presentation-uploader/container.jsx | 11 +++++--- 6 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 bbb-graphql-actions/src/actions/presentationExport.ts diff --git a/bbb-graphql-actions/src/actions/presentationExport.ts b/bbb-graphql-actions/src/actions/presentationExport.ts new file mode 100644 index 0000000000..4c2b9daf6e --- /dev/null +++ b/bbb-graphql-actions/src/actions/presentationExport.ts @@ -0,0 +1,27 @@ +import { RedisMessage } from '../types'; +import {throwErrorIfNotPresenter} from "../imports/validation"; + +export default function buildRedisMessage(sessionVariables: Record, input: Record): RedisMessage { + throwErrorIfNotPresenter(sessionVariables); + const eventName = `MakePresentationDownloadReqMsg`; + + const routing = { + meetingId: sessionVariables['x-hasura-meetingid'] as String, + userId: sessionVariables['x-hasura-userid'] as String + }; + + const header = { + name: eventName, + meetingId: routing.meetingId, + userId: routing.userId + }; + + const body = { + presId: input.presentationId, + allPages: true, + fileStateType: input.fileStateType, + pages: [], + }; + + return { eventName, routing, header, body }; +} diff --git a/bbb-graphql-actions/src/actions/presentationSetDownloadable.ts b/bbb-graphql-actions/src/actions/presentationSetDownloadable.ts index cab7190a5a..33ee462298 100644 --- a/bbb-graphql-actions/src/actions/presentationSetDownloadable.ts +++ b/bbb-graphql-actions/src/actions/presentationSetDownloadable.ts @@ -1,5 +1,4 @@ import { RedisMessage } from '../types'; -import { ValidationError } from '../types/ValidationError'; import {throwErrorIfNotPresenter} from "../imports/validation"; export default function buildRedisMessage(sessionVariables: Record, input: Record): RedisMessage { diff --git a/bbb-graphql-server/metadata/actions.graphql b/bbb-graphql-server/metadata/actions.graphql index 6277092c23..dab9f538eb 100644 --- a/bbb-graphql-server/metadata/actions.graphql +++ b/bbb-graphql-server/metadata/actions.graphql @@ -280,6 +280,13 @@ type Mutation { ): Boolean } +type Mutation { + presentationExport( + presentationId: String! + fileStateType: String! + ): Boolean +} + type Mutation { presentationRemove( presentationId: String! diff --git a/bbb-graphql-server/metadata/actions.yaml b/bbb-graphql-server/metadata/actions.yaml index 3a87de56bd..8cf4d4bdb3 100644 --- a/bbb-graphql-server/metadata/actions.yaml +++ b/bbb-graphql-server/metadata/actions.yaml @@ -245,6 +245,13 @@ actions: handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}' permissions: - role: bbb_client + - name: presentationExport + definition: + kind: synchronous + handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}' + permissions: + - role: bbb_client + comment: presentationExport - name: presentationRemove definition: kind: synchronous diff --git a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx index 79910f22df..e5d082205e 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/mutations.jsx @@ -45,6 +45,17 @@ export const PRESENTATION_SET_DOWNLOADABLE = gql` } `; +export const PRESENTATION_EXPORT = gql` + mutation PresentationExport( + $presentationId: String!, + $fileStateType: String!,) { + presentationExport( + presentationId: $presentationId, + fileStateType: $fileStateType, + ) + } +`; + export const PRESENTATION_SET_CURRENT = gql` mutation PresentationSetCurrent($presentationId: String!) { presentationSetCurrent( @@ -84,6 +95,7 @@ export default { PRESENTATION_SET_WRITERS, PRESENTATION_SET_PAGE, PRESENTATION_SET_DOWNLOADABLE, + PRESENTATION_EXPORT, PRESENTATION_SET_CURRENT, PRESENTATION_REMOVE, PRES_ANNOTATION_DELETE, diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx index 181f09cbf9..6f60955ed3 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx @@ -17,7 +17,12 @@ import { PRESENTATIONS_SUBSCRIPTION, } from '/imports/ui/components/whiteboard/queries'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; -import { PRESENTATION_SET_DOWNLOADABLE, PRESENTATION_SET_CURRENT, PRESENTATION_REMOVE } from '../mutations'; +import { + PRESENTATION_SET_DOWNLOADABLE, + PRESENTATION_EXPORT, + PRESENTATION_SET_CURRENT, + PRESENTATION_REMOVE, +} from '../mutations'; const PRESENTATION_CONFIG = Meteor.settings.public.presentation; @@ -32,14 +37,14 @@ const PresentationUploaderContainer = (props) => { const currentPresentation = presentations.find((p) => p.current)?.presentationId || ''; const [presentationSetDownloadable] = useMutation(PRESENTATION_SET_DOWNLOADABLE); + const [presentationExport] = useMutation(PRESENTATION_EXPORT); const [presentationSetCurrent] = useMutation(PRESENTATION_SET_CURRENT); const [presentationRemove] = useMutation(PRESENTATION_REMOVE); const exportPresentation = (presentationId, fileStateType) => { - presentationSetDownloadable({ + presentationExport({ variables: { presentationId, - downloadable: true, fileStateType, }, }); From e37be06ec2a0e7ed55a6053846c48b96fded65c1 Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Fri, 26 Jan 2024 11:53:34 -0500 Subject: [PATCH 0261/1039] Revert "docs: Force trailingSlash" --- docs/docusaurus.config.js | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 6d4f9a9e89..867997ca54 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -13,7 +13,6 @@ const config = { onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', favicon: 'img/favicon.ico', - trailingSlash: true, // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. From d84a8134571e0a91500a264b90274150c963754f Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Fri, 26 Jan 2024 12:09:40 -0500 Subject: [PATCH 0262/1039] docs: enable trailingSlashes (2) and handle redirects --- docs/docusaurus.config.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 6d4f9a9e89..ee4b1e977e 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -68,23 +68,23 @@ const config = { fromExtensions: ['html', 'htm'], redirects: [ { - to: "/2.6/new-features", - from: "/2.6/new" + to: "/2.6/new-features/", + from: "/2.6/new/" }, { - to: "/2.6/new-features", + to: "/2.6/new-features/", from: "/2.6/new.html" }, { - to: "/new-features", - from: "/2.7/new-features" + to: "/new-features/", + from: "/2.7/new-features/" }, { - to: "/development/api", + to: "/development/api/", from: "/dev/api.html" }, { - to: "/greenlight/v3/migration", + to: "/greenlight/v3/migration/", from: "/greenlight_v3/gl3-migration.html" } ], From f1d74a38e85ffce247450540db1fbb5dedafc55f Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Fri, 26 Jan 2024 12:18:56 -0500 Subject: [PATCH 0263/1039] docs: Force trailingSlash --- docs/docusaurus.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index f0daa09b4c..ee4b1e977e 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -13,6 +13,7 @@ const config = { onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', favicon: 'img/favicon.ico', + trailingSlash: true, // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. From 3be1f84e979e59e7bda2fc4c6fac4a1496dbbda7 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Fri, 26 Jan 2024 14:42:35 -0300 Subject: [PATCH 0264/1039] Revert "Prevent graphql-middlware from re-transmitting Mutations" --- .../internal/hascli/conn/writer/writer.go | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go index d6fa628f2e..9d7ea23837 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go +++ b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go @@ -96,23 +96,20 @@ RangeLoop: jsonPatchSupported = true } - //Not storing Mutations because they will not be retransmitted in case of reconnection - if messageType != common.Mutation { - browserConnection.ActiveSubscriptionsMutex.Lock() - browserConnection.ActiveSubscriptions[queryId] = common.GraphQlSubscription{ - Id: queryId, - Message: fromBrowserMessageAsMap, - OperationName: operationName, - StreamCursorField: streamCursorField, - StreamCursorVariableName: streamCursorVariableName, - StreamCursorCurrValue: streamCursorInitialValue, - LastSeenOnHasuraConnetion: hc.Id, - JsonPatchSupported: jsonPatchSupported, - Type: messageType, - } - // log.Tracef("Current queries: %v", browserConnection.ActiveSubscriptions) - browserConnection.ActiveSubscriptionsMutex.Unlock() + browserConnection.ActiveSubscriptionsMutex.Lock() + browserConnection.ActiveSubscriptions[queryId] = common.GraphQlSubscription{ + Id: queryId, + Message: fromBrowserMessageAsMap, + OperationName: operationName, + StreamCursorField: streamCursorField, + StreamCursorVariableName: streamCursorVariableName, + StreamCursorCurrValue: streamCursorInitialValue, + LastSeenOnHasuraConnetion: hc.Id, + JsonPatchSupported: jsonPatchSupported, + Type: messageType, } + // log.Tracef("Current queries: %v", browserConnection.ActiveSubscriptions) + browserConnection.ActiveSubscriptionsMutex.Unlock() } if fromBrowserMessageAsMap["type"] == "stop" { From 06b746318386f8928ff7c04c37d9dd454167ae0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Thu, 25 Jan 2024 14:53:36 -0300 Subject: [PATCH 0265/1039] migrate requestUserInformation --- .../imports/api/users-infos/server/methods.js | 2 -- .../server/methods/requestUserInformation.js | 27 ------------------- .../user-actions/component.tsx | 9 +++++-- .../user-actions/mutations.tsx | 9 +++++++ 4 files changed, 16 insertions(+), 31 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/users-infos/server/methods/requestUserInformation.js diff --git a/bigbluebutton-html5/imports/api/users-infos/server/methods.js b/bigbluebutton-html5/imports/api/users-infos/server/methods.js index 3b11b958f5..f032653e6c 100644 --- a/bigbluebutton-html5/imports/api/users-infos/server/methods.js +++ b/bigbluebutton-html5/imports/api/users-infos/server/methods.js @@ -1,8 +1,6 @@ import { Meteor } from 'meteor/meteor'; -import requestUserInformation from './methods/requestUserInformation'; import removeUserInformation from './methods/removeUserInformation'; Meteor.methods({ - requestUserInformation, removeUserInformation, }); diff --git a/bigbluebutton-html5/imports/api/users-infos/server/methods/requestUserInformation.js b/bigbluebutton-html5/imports/api/users-infos/server/methods/requestUserInformation.js deleted file mode 100644 index 6a9b2c4daa..0000000000 --- a/bigbluebutton-html5/imports/api/users-infos/server/methods/requestUserInformation.js +++ /dev/null @@ -1,27 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; -import RedisPubSub from '/imports/startup/server/redis'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import Logger from '/imports/startup/server/logger'; - -export default function getUserInformation(externalUserId) { - try { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toThirdParty; - const EVENT_NAME = 'LookUpUserReqMsg'; - - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(externalUserId, String); - - const payload = { - externalUserId, - }; - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method getUserInformation ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/component.tsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/component.tsx index 4583f022f0..bb48ad270c 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/component.tsx @@ -9,6 +9,7 @@ import { SET_ROLE, USER_EJECT_CAMERAS, CHAT_CREATE_WITH_USER, + REQUEST_USER_INFO, } from './mutations'; import { SET_CAMERA_PINNED, @@ -26,7 +27,6 @@ import { isVoiceOnlyUser, } from './service'; -import { makeCall } from '/imports/ui/services/api'; import { isChatEnabled } from '/imports/ui/services/features'; import { layoutDispatch } from '/imports/ui/components/layout/context'; import { PANELS, ACTIONS } from '/imports/ui/components/layout/enums'; @@ -295,6 +295,7 @@ const UserActions: React.FC = ({ const [setEmojiStatus] = useMutation(SET_EMOJI_STATUS); const [setLocked] = useMutation(SET_LOCKED); const [userEjectCameras] = useMutation(USER_EJECT_CAMERAS); + const [requestUserInfo] = useMutation(REQUEST_USER_INFO); const removeUser = (userId: string, banUser: boolean) => { if (isVoiceOnlyUser(user.userId)) { @@ -508,7 +509,11 @@ const UserActions: React.FC = ({ key: 'directoryLookup', label: intl.formatMessage(messages.DirectoryLookupLabel), onClick: () => { - makeCall('requestUserInformation', user.extId); + requestUserInfo({ + variables: { + extId: user.extId, + }, + }); setSelected(false); }, icon: 'user', diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/mutations.tsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/mutations.tsx index 9b2245ecb6..7297949211 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/mutations.tsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/mutations.tsx @@ -33,9 +33,18 @@ export const CHAT_CREATE_WITH_USER = gql` } `; +export const REQUEST_USER_INFO = gql` + mutation RequestUserInfo($extId: String!) { + userThirdPartyInfoResquest( + externalUserId: $extId + ) + } +`; + export default { SET_AWAY, SET_ROLE, USER_EJECT_CAMERAS, CHAT_CREATE_WITH_USER, + REQUEST_USER_INFO, }; From 349535b5ceb3c8d070601189c08925a03447ba6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Fri, 26 Jan 2024 10:29:52 -0300 Subject: [PATCH 0266/1039] migrate userShareWebcam --- .../api/video-streams/server/methods.js | 2 -- .../server/methods/userShareWebcam.js | 34 ------------------- .../components/video-provider/component.jsx | 4 ++- .../components/video-provider/container.jsx | 17 +++++++++- .../ui/components/video-provider/mutations.ts | 13 +++++++ .../ui/components/video-provider/service.js | 1 + 6 files changed, 33 insertions(+), 38 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/video-streams/server/methods/userShareWebcam.js create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/mutations.ts diff --git a/bigbluebutton-html5/imports/api/video-streams/server/methods.js b/bigbluebutton-html5/imports/api/video-streams/server/methods.js index f0cc07f13b..7a33758c50 100644 --- a/bigbluebutton-html5/imports/api/video-streams/server/methods.js +++ b/bigbluebutton-html5/imports/api/video-streams/server/methods.js @@ -1,8 +1,6 @@ import { Meteor } from 'meteor/meteor'; -import userShareWebcam from './methods/userShareWebcam'; import userUnshareWebcam from './methods/userUnshareWebcam'; Meteor.methods({ - userShareWebcam, userUnshareWebcam, }); diff --git a/bigbluebutton-html5/imports/api/video-streams/server/methods/userShareWebcam.js b/bigbluebutton-html5/imports/api/video-streams/server/methods/userShareWebcam.js deleted file mode 100644 index 82098a5ea7..0000000000 --- a/bigbluebutton-html5/imports/api/video-streams/server/methods/userShareWebcam.js +++ /dev/null @@ -1,34 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; -import Logger from '/imports/startup/server/logger'; -import RedisPubSub from '/imports/startup/server/redis'; -import { extractCredentials } from '/imports/api/common/server/helpers'; - -export default function userShareWebcam(stream) { - try { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'UserBroadcastCamStartMsg'; - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(stream, String); - - Logger.info(`user sharing webcam: ${meetingId} ${requesterUserId}`); - - // const actionName = 'joinVideo'; - /* TODO throw an error if user has no permission to share webcam - if (!isAllowedTo(actionName, credentials)) { - throw new Meteor.Error('not-allowed', `You are not allowed to share webcam`); - } */ - - const payload = { - stream, - }; - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method userShareWebcam ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx index cf507eabe0..1b33880864 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx @@ -123,6 +123,7 @@ const propTypes = { currentVideoPageIndex: PropTypes.number.isRequired, totalNumberOfStreams: PropTypes.number.isRequired, isMeteorConnected: PropTypes.bool.isRequired, + playStart: PropTypes.func.isRequired, }; class VideoProvider extends Component { @@ -1153,6 +1154,7 @@ class VideoProvider extends Component { handlePlayStart(message) { const { cameraId: stream, role } = message; const peer = this.webRtcPeers[stream]; + const { playStart } = this.props; if (peer) { logger.info({ @@ -1169,7 +1171,7 @@ class VideoProvider extends Component { this.clearRestartTimers(stream); this.attachVideoStream(stream); - VideoService.playStart(stream); + playStart(stream); } else { logger.warn({ logCode: 'video_provider_playstart_no_peer', diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/container.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/container.jsx index f5480a3846..379bc4edec 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/container.jsx @@ -1,14 +1,29 @@ import React from 'react'; import { withTracker } from 'meteor/react-meteor-data'; +import { useMutation } from '@apollo/client'; import VideoProvider from './component'; import VideoService from './service'; import { sortVideoStreams } from '/imports/ui/components/video-provider/stream-sorting'; +import { CAMERA_BROADCAST_START } from './mutations'; const { defaultSorting: DEFAULT_SORTING } = Meteor.settings.public.kurento.cameraSortingModes; const VideoProviderContainer = ({ children, ...props }) => { const { streams, isGridEnabled } = props; - return (!streams.length && !isGridEnabled ? null : {children}); + const [cameraBroadcastStart] = useMutation(CAMERA_BROADCAST_START); + + const sendUserShareWebcam = (cameraId) => { + cameraBroadcastStart({ variables: { cameraId } }); + }; + + const playStart = (cameraId) => { + if (VideoService.isLocalStream(cameraId)) { + sendUserShareWebcam(cameraId); + VideoService.joinedVideo(); + } + }; + + return (!streams.length && !isGridEnabled ? null : {children}); }; export default withTracker(({ swapLayout, ...rest }) => { diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/mutations.ts b/bigbluebutton-html5/imports/ui/components/video-provider/mutations.ts new file mode 100644 index 0000000000..f5d6d7ebea --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/mutations.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const CAMERA_BROADCAST_START = gql` + mutation CameraBroadcastStart($cameraId: String!) { + cameraBroadcastStart( + stream: $cameraId + ) + } +`; + +export default { + CAMERA_BROADCAST_START, +}; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/service.js b/bigbluebutton-html5/imports/ui/components/video-provider/service.js index 956b493022..5a1b108a28 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/service.js +++ b/bigbluebutton-html5/imports/ui/components/video-provider/service.js @@ -1073,4 +1073,5 @@ export default { getPreloadedStream: () => videoService.getPreloadedStream(), getStats: () => videoService.getStats(), updatePeerDictionaryReference: (newRef) => videoService.updatePeerDictionaryReference(newRef), + joinedVideo: () => videoService.joinedVideo(), }; From deb4a17712c77e21bc7dd01e8ae9fc527e825e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Fri, 26 Jan 2024 13:21:58 -0300 Subject: [PATCH 0267/1039] migrate userUnshareWebcam --- .../imports/api/video-streams/server/index.js | 1 - .../api/video-streams/server/methods.js | 6 ---- .../server/methods/userUnshareWebcam.js | 34 ------------------- .../breakout-join-confirmation/component.jsx | 4 ++- .../breakout-join-confirmation/container.jsx | 7 ++++ .../ui/components/breakout-room/component.jsx | 3 +- .../ui/components/breakout-room/container.jsx | 7 ++++ .../ui/components/video-preview/container.jsx | 10 ++++-- .../components/video-provider/component.jsx | 29 ++++++++++------ .../components/video-provider/container.jsx | 21 ++++++++++-- .../ui/components/video-provider/mutations.ts | 9 +++++ .../ui/components/video-provider/service.js | 27 ++++++++------- .../video-provider/video-button/component.jsx | 6 ++-- .../video-provider/video-button/container.jsx | 9 +++++ 14 files changed, 101 insertions(+), 72 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/video-streams/server/methods.js delete mode 100644 bigbluebutton-html5/imports/api/video-streams/server/methods/userUnshareWebcam.js diff --git a/bigbluebutton-html5/imports/api/video-streams/server/index.js b/bigbluebutton-html5/imports/api/video-streams/server/index.js index eff5e3f61e..888b8bec5d 100644 --- a/bigbluebutton-html5/imports/api/video-streams/server/index.js +++ b/bigbluebutton-html5/imports/api/video-streams/server/index.js @@ -1,3 +1,2 @@ import './eventHandlers'; -import './methods'; import './publisher'; diff --git a/bigbluebutton-html5/imports/api/video-streams/server/methods.js b/bigbluebutton-html5/imports/api/video-streams/server/methods.js deleted file mode 100644 index 7a33758c50..0000000000 --- a/bigbluebutton-html5/imports/api/video-streams/server/methods.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import userUnshareWebcam from './methods/userUnshareWebcam'; - -Meteor.methods({ - userUnshareWebcam, -}); diff --git a/bigbluebutton-html5/imports/api/video-streams/server/methods/userUnshareWebcam.js b/bigbluebutton-html5/imports/api/video-streams/server/methods/userUnshareWebcam.js deleted file mode 100644 index afae00c436..0000000000 --- a/bigbluebutton-html5/imports/api/video-streams/server/methods/userUnshareWebcam.js +++ /dev/null @@ -1,34 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; -import Logger from '/imports/startup/server/logger'; -import RedisPubSub from '/imports/startup/server/redis'; -import { extractCredentials } from '/imports/api/common/server/helpers'; - -export default function userUnshareWebcam(stream) { - try { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'UserBroadcastCamStopMsg'; - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - check(stream, String); - - Logger.info(`user unsharing webcam: ${meetingId} ${requesterUserId}`); - - // const actionName = 'joinVideo'; - /* TODO throw an error if user has no permission to share webcam - if (!isAllowedTo(actionName, credentials)) { - throw new Meteor.Error('not-allowed', `You are not allowed to share webcam`); - } */ - - const payload = { - stream, - }; - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method userUnshareWebcam ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/component.jsx b/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/component.jsx index 2e94ab0583..7eaefe563d 100755 --- a/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/component.jsx @@ -54,6 +54,7 @@ const propTypes = { requestJoinURL: PropTypes.func.isRequired, breakouts: PropTypes.arrayOf(Object).isRequired, breakoutName: PropTypes.string.isRequired, + sendUserUnshareWebcam: PropTypes.func.isRequired, }; let interval = null; @@ -101,6 +102,7 @@ class BreakoutJoinConfirmation extends Component { voiceUserJoined, requestJoinURL, amIPresenter, + sendUserUnshareWebcam, } = this.props; const { selectValue } = this.state; @@ -120,7 +122,7 @@ class BreakoutJoinConfirmation extends Component { } VideoService.storeDeviceIds(); - VideoService.exitVideo(); + VideoService.exitVideo(sendUserUnshareWebcam); if (amIPresenter) screenshareHasEnded(); if (url === '') { logger.error({ diff --git a/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/container.jsx b/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/container.jsx index e7955e7aba..32868c977b 100755 --- a/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/container.jsx @@ -8,6 +8,7 @@ import AudioManager from '/imports/ui/services/audio-manager'; import BreakoutJoinConfirmationComponent from './component'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; import { BREAKOUT_ROOM_REQUEST_JOIN_URL } from '../breakout-room/mutations'; +import { CAMERA_BROADCAST_STOP } from '../video-provider/mutations'; const BreakoutJoinConfirmationContrainer = (props) => { const { data: currentUserData } = useCurrentUser((user) => ({ @@ -16,6 +17,11 @@ const BreakoutJoinConfirmationContrainer = (props) => { const amIPresenter = currentUserData?.presenter; const [breakoutRoomRequestJoinURL] = useMutation(BREAKOUT_ROOM_REQUEST_JOIN_URL); + const [cameraBroadcastStop] = useMutation(CAMERA_BROADCAST_STOP); + + const sendUserUnshareWebcam = (cameraId) => { + cameraBroadcastStop({ variables: { cameraId } }); + }; const requestJoinURL = (breakoutRoomId) => { breakoutRoomRequestJoinURL({ variables: { breakoutRoomId } }); @@ -25,6 +31,7 @@ const BreakoutJoinConfirmationContrainer = (props) => { {...props} amIPresenter={amIPresenter} requestJoinURL={requestJoinURL} + sendUserUnshareWebcam={sendUserUnshareWebcam} /> }; diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx b/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx index 6da2905506..6ae0a57d76 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx @@ -292,6 +292,7 @@ class BreakoutRoom extends PureComponent { rejoinAudio, setBreakoutAudioTransferStatus, getBreakoutAudioTransferStatus, + sendUserUnshareWebcam, } = this.props; const { @@ -362,7 +363,7 @@ class BreakoutRoom extends PureComponent { extraInfo: { logType: 'user_action' }, }, 'joining breakout room closed audio in the main room'); VideoService.storeDeviceIds(); - VideoService.exitVideo(); + VideoService.exitVideo(sendUserUnshareWebcam); if (amIPresenter) screenshareHasEnded(); Tracker.autorun((c) => { diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx b/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx index 28dbd6a92b..5786b86423 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx @@ -19,6 +19,7 @@ import { BREAKOUT_ROOM_REQUEST_JOIN_URL, } from './mutations'; import logger from '/imports/startup/client/logger'; +import { CAMERA_BROADCAST_STOP } from '../video-provider/mutations'; const BreakoutContainer = (props) => { const layoutContextDispatch = layoutDispatch(); @@ -34,6 +35,11 @@ const BreakoutContainer = (props) => { const [breakoutRoomSetTime] = useMutation(BREAKOUT_ROOM_SET_TIME); const [breakoutRoomTransfer] = useMutation(USER_TRANSFER_VOICE_TO_MEETING); const [breakoutRoomRequestJoinURL] = useMutation(BREAKOUT_ROOM_REQUEST_JOIN_URL); + const [cameraBroadcastStop] = useMutation(CAMERA_BROADCAST_STOP); + + const sendUserUnshareWebcam = (cameraId) => { + cameraBroadcastStop({ variables: { cameraId } }); + }; const endAllBreakouts = () => { Service.setCapturedContentUploading(); @@ -67,6 +73,7 @@ const BreakoutContainer = (props) => { setBreakoutsTime={setBreakoutsTime} transferUserToMeeting={transferUserToMeeting} requestJoinURL={requestJoinURL} + sendUserUnshareWebcam={sendUserUnshareWebcam} {...{ layoutContextDispatch, isRTL, amIModerator, ...props }} />; }; diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx b/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx index 6c90090887..99e77a6a19 100755 --- a/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx @@ -8,11 +8,17 @@ import ScreenShareService from '/imports/ui/components/screenshare/service'; import logger from '/imports/startup/client/logger'; import { SCREENSHARING_ERRORS } from '/imports/api/screenshare/client/bridge/errors'; import { EXTERNAL_VIDEO_STOP } from '../external-video-player/mutations'; +import { CAMERA_BROADCAST_STOP } from '../video-provider/mutations'; const VideoPreviewContainer = (props) => ; export default withTracker(({ setIsOpen, callbackToClose }) => { const [stopExternalVideoShare] = useMutation(EXTERNAL_VIDEO_STOP); + const [cameraBroadcastStop] = useMutation(CAMERA_BROADCAST_STOP); + + const sendUserUnshareWebcam = (cameraId) => { + cameraBroadcastStop({ variables: { cameraId } }); + }; return { startSharing: (deviceId) => { @@ -47,9 +53,9 @@ export default withTracker(({ setIsOpen, callbackToClose }) => { setIsOpen(false); if (deviceId) { const streamId = VideoService.getMyStreamId(deviceId); - if (streamId) VideoService.stopVideo(streamId); + if (streamId) VideoService.stopVideo(streamId, sendUserUnshareWebcam); } else { - VideoService.exitVideo(); + VideoService.exitVideo(sendUserUnshareWebcam); } }, stopSharingCameraAsContent: () => { diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx index 1b33880864..c225d1400c 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx @@ -124,11 +124,13 @@ const propTypes = { totalNumberOfStreams: PropTypes.number.isRequired, isMeteorConnected: PropTypes.bool.isRequired, playStart: PropTypes.func.isRequired, + sendUserUnshareWebcam: PropTypes.func.isRequired, }; class VideoProvider extends Component { - static onBeforeUnload() { - VideoService.onBeforeUnload(); + onBeforeUnload() { + const { sendUserUnshareWebcam } = this.props; + VideoService.onBeforeUnload(sendUserUnshareWebcam); } static shouldAttachVideoStream(peer, videoElement) { @@ -183,13 +185,14 @@ class VideoProvider extends Component { { leading: false, trailing: true }, ); this.startVirtualBackgroundByDrop = this.startVirtualBackgroundByDrop.bind(this); + this.onBeforeUnload = this.onBeforeUnload.bind(this); } componentDidMount() { this._isMounted = true; VideoService.updatePeerDictionaryReference(this.webRtcPeers); this.ws = this.openWs(); - window.addEventListener('beforeunload', VideoProvider.onBeforeUnload); + window.addEventListener('beforeunload', this.onBeforeUnload); } componentDidUpdate(prevProps) { @@ -197,7 +200,8 @@ class VideoProvider extends Component { isUserLocked, streams, currentVideoPageIndex, - isMeteorConnected + isMeteorConnected, + sendUserUnshareWebcam, } = this.props; const { socketOpen } = this.state; @@ -206,7 +210,7 @@ class VideoProvider extends Component { && prevProps.currentVideoPageIndex !== currentVideoPageIndex; if (isMeteorConnected && socketOpen) this.updateStreams(streams, shouldDebounce); - if (!prevProps.isUserLocked && isUserLocked) VideoService.lockUser(); + if (!prevProps.isUserLocked && isUserLocked) VideoService.lockUser(sendUserUnshareWebcam); // Signaling socket expired its retries and meteor is connected - create // a new signaling socket instance from scratch @@ -218,6 +222,7 @@ class VideoProvider extends Component { } componentWillUnmount() { + const { sendUserUnshareWebcam } = this.props; this._isMounted = false; VideoService.updatePeerDictionaryReference({}); @@ -225,8 +230,8 @@ class VideoProvider extends Component { this.ws.onopen = null; this.ws.onclose = null; - window.removeEventListener('beforeunload', VideoProvider.onBeforeUnload); - VideoService.exitVideo(); + window.removeEventListener('beforeunload', this.onBeforeUnload); + VideoService.exitVideo(sendUserUnshareWebcam); Object.keys(this.webRtcPeers).forEach((stream) => { this.stopWebRTCPeer(stream, false); }); @@ -334,12 +339,13 @@ class VideoProvider extends Component { } onWsClose() { + const { sendUserUnshareWebcam } = this.props; logger.info({ logCode: 'video_provider_onwsclose', }, 'Multiple video provider websocket connection closed.'); this.clearWSHeartbeat(); - VideoService.exitVideo(); + VideoService.exitVideo(sendUserUnshareWebcam); // Media is currently tied to signaling state - so if signaling shuts down, // media will shut down server-side. This cleans up our local state faster // and notify the state change as failed so the UI rolls back to the placeholder @@ -577,6 +583,7 @@ class VideoProvider extends Component { stopWebRTCPeer(stream, restarting = false) { const isLocal = VideoService.isLocalStream(stream); + const { sendUserUnshareWebcam } = this.props; // in this case, 'closed' state is not caused by an error; // we stop listening to prevent this from being treated as an error @@ -587,7 +594,7 @@ class VideoProvider extends Component { } if (isLocal) { - VideoService.stopVideo(stream); + VideoService.stopVideo(stream, sendUserUnshareWebcam); } const role = VideoService.getRole(isLocal); @@ -1181,7 +1188,7 @@ class VideoProvider extends Component { } handleSFUError(message) { - const { intl, streams } = this.props; + const { intl, streams, sendUserUnshareWebcam } = this.props; const { code, reason, streamId } = message; const isLocal = VideoService.isLocalStream(streamId); const role = VideoService.getRole(isLocal); @@ -1200,7 +1207,7 @@ class VideoProvider extends Component { // The publisher instance received an error from the server. There's no reconnect, // stop it. VideoService.notify(intl.formatMessage(intlSFUErrors[code] || intlSFUErrors[2200])); - VideoService.stopVideo(streamId); + VideoService.stopVideo(streamId, sendUserUnshareWebcam); } else { const peer = this.webRtcPeers[streamId]; const stillExists = streams.some(({ stream }) => streamId === stream); diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/container.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/container.jsx index 379bc4edec..a10120bc3d 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/container.jsx @@ -4,18 +4,23 @@ import { useMutation } from '@apollo/client'; import VideoProvider from './component'; import VideoService from './service'; import { sortVideoStreams } from '/imports/ui/components/video-provider/stream-sorting'; -import { CAMERA_BROADCAST_START } from './mutations'; +import { CAMERA_BROADCAST_START, CAMERA_BROADCAST_STOP } from './mutations'; const { defaultSorting: DEFAULT_SORTING } = Meteor.settings.public.kurento.cameraSortingModes; const VideoProviderContainer = ({ children, ...props }) => { const { streams, isGridEnabled } = props; const [cameraBroadcastStart] = useMutation(CAMERA_BROADCAST_START); + const [cameraBroadcastStop] = useMutation(CAMERA_BROADCAST_STOP); const sendUserShareWebcam = (cameraId) => { cameraBroadcastStart({ variables: { cameraId } }); }; + const sendUserUnshareWebcam = (cameraId) => { + cameraBroadcastStop({ variables: { cameraId } }); + }; + const playStart = (cameraId) => { if (VideoService.isLocalStream(cameraId)) { sendUserShareWebcam(cameraId); @@ -23,7 +28,19 @@ const VideoProviderContainer = ({ children, ...props }) => { } }; - return (!streams.length && !isGridEnabled ? null : {children}); + return ( + !streams.length && !isGridEnabled + ? null + : ( + + {children} + + ) + ); }; export default withTracker(({ swapLayout, ...rest }) => { diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/mutations.ts b/bigbluebutton-html5/imports/ui/components/video-provider/mutations.ts index f5d6d7ebea..5c7a20eda7 100644 --- a/bigbluebutton-html5/imports/ui/components/video-provider/mutations.ts +++ b/bigbluebutton-html5/imports/ui/components/video-provider/mutations.ts @@ -8,6 +8,15 @@ export const CAMERA_BROADCAST_START = gql` } `; +export const CAMERA_BROADCAST_STOP = gql` + mutation CameraBroadcastStop($cameraId: String!) { + cameraBroadcastStop( + stream: $cameraId + ) + } +`; + export default { CAMERA_BROADCAST_START, + CAMERA_BROADCAST_STOP, }; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/service.js b/bigbluebutton-html5/imports/ui/components/video-provider/service.js index 5a1b108a28..bdb69c4389 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/service.js +++ b/bigbluebutton-html5/imports/ui/components/video-provider/service.js @@ -164,7 +164,7 @@ class VideoService { Session.set('deviceIds', deviceIds.join()); } - exitVideo() { + exitVideo(sendUserUnshareWebcam) { if (this.isConnected) { logger.info({ logCode: 'video_provider_unsharewebcam', @@ -176,7 +176,7 @@ class VideoService { }, { fields: { stream: 1 } }, ).fetch(); - streams.forEach(s => this.sendUserUnshareWebcam(s.stream)); + streams.forEach(s => sendUserUnshareWebcam(s.stream)); this.exitedVideo(); } } @@ -187,7 +187,7 @@ class VideoService { this.isConnected = false; } - stopVideo(cameraId) { + stopVideo(cameraId, sendUserUnshareWebcam) { const streams = VideoStreams.find( { meetingId: Auth.meetingID, @@ -201,7 +201,7 @@ class VideoService { // Check if the target (cameraId) stream exists in the remote collection. // If it does, means it was successfully shared. So do the full stop procedure. if (hasTargetStream) { - this.sendUserUnshareWebcam(cameraId); + sendUserUnshareWebcam(cameraId); } if (!hasOtherStream) { @@ -720,9 +720,9 @@ class VideoService { }, { fields: {} }) && this.disableCam(); } - lockUser() { + lockUser(sendUserUnshareWebcam) { if (this.isConnected) { - this.exitVideo(); + this.exitVideo(sendUserUnshareWebcam); } } @@ -776,8 +776,8 @@ class VideoService { } } - onBeforeUnload() { - this.exitVideo(); + onBeforeUnload(sendUserUnshareWebcam) { + this.exitVideo(sendUserUnshareWebcam); } getStatus() { @@ -1025,14 +1025,17 @@ const videoService = new VideoService(); export default { storeDeviceIds: () => videoService.storeDeviceIds(), - exitVideo: () => videoService.exitVideo(), + exitVideo: (sendUserUnshareWebcam) => videoService.exitVideo(sendUserUnshareWebcam), joinVideo: deviceId => videoService.joinVideo(deviceId), - stopVideo: cameraId => videoService.stopVideo(cameraId), + stopVideo: (cameraId, sendUserUnshareWebcam) => videoService.stopVideo( + cameraId, + sendUserUnshareWebcam, + ), getVideoStreams: () => videoService.getVideoStreams(), getInfo: () => videoService.getInfo(), getMyStreamId: deviceId => videoService.getMyStreamId(deviceId), isUserLocked: () => videoService.isUserLocked(), - lockUser: () => videoService.lockUser(), + lockUser: (sendUserUnshareWebcam) => videoService.lockUser(sendUserUnshareWebcam), getAuthenticatedURL: () => videoService.getAuthenticatedURL(), isLocalStream: cameraId => videoService.isLocalStream(cameraId), hasVideoStream: () => videoService.hasVideoStream(), @@ -1050,7 +1053,7 @@ export default { isMultipleCamerasEnabled: () => videoService.isMultipleCamerasEnabled(), mirrorOwnWebcam: userId => videoService.mirrorOwnWebcam(userId), hasCapReached: () => videoService.hasCapReached(), - onBeforeUnload: () => videoService.onBeforeUnload(), + onBeforeUnload: (sendUserUnshareWebcam) => videoService.onBeforeUnload(sendUserUnshareWebcam), notify: message => notify(message, 'error', 'video'), updateNumberOfDevices: devices => videoService.updateNumberOfDevices(devices), applyCameraProfile: debounce( diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-button/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-button/component.jsx index 5aa2588d3e..2d5020a827 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-button/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-button/component.jsx @@ -65,6 +65,7 @@ const propTypes = { id: PropTypes.string, type: PropTypes.string, })).isRequired, + sendUserUnshareWebcam: PropTypes.func.isRequired, }; const JoinVideoButton = ({ @@ -74,6 +75,7 @@ const JoinVideoButton = ({ disableReason, updateSettings, cameraSettingsDropdownItems, + sendUserUnshareWebcam, }) => { const { isMobile } = deviceInfo; const isMobileSharingCamera = hasVideoStream && isMobile; @@ -108,12 +110,12 @@ const JoinVideoButton = ({ const handleOnClick = debounce(() => { switch (status) { case 'videoConnecting': - VideoService.stopVideo(); + VideoService.stopVideo(undefined, sendUserUnshareWebcam); break; case 'connected': default: if (exitVideo()) { - VideoService.exitVideo(); + VideoService.exitVideo(sendUserUnshareWebcam); } else { setForceOpen(isMobileSharingCamera); setVideoPreviewModalIsOpen(true); diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-button/container.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-button/container.jsx index f8c98edd48..e17ae01cc8 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-button/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-button/container.jsx @@ -2,12 +2,14 @@ import React from 'react'; import { useContext } from 'react'; import { withTracker } from 'meteor/react-meteor-data'; import { injectIntl } from 'react-intl'; +import { useMutation } from '@apollo/client'; import JoinVideoButton from './component'; import VideoService from '../service'; import { updateSettings, } from '/imports/ui/components/settings/service'; import { PluginsContext } from '/imports/ui/components/components-data/plugin-context/context'; +import { CAMERA_BROADCAST_STOP } from '../mutations'; const JoinVideoOptionsContainer = (props) => { const { @@ -19,6 +21,12 @@ const JoinVideoOptionsContainer = (props) => { ...restProps } = props; + const [cameraBroadcastStop] = useMutation(CAMERA_BROADCAST_STOP); + + const sendUserUnshareWebcam = (cameraId) => { + cameraBroadcastStop({ variables: { cameraId } }); + }; + const { pluginsExtensibleAreasAggregatedState, } = useContext(PluginsContext); @@ -35,6 +43,7 @@ const JoinVideoOptionsContainer = (props) => { updateSettings, disableReason, status, + sendUserUnshareWebcam, ...restProps, }} /> From f5cbad283c3aae6688b023b1250c6af7a8322766 Mon Sep 17 00:00:00 2001 From: Gabriel Porfirio Date: Mon, 29 Jan 2024 09:25:45 -0300 Subject: [PATCH 0268/1039] removes .only --- .../playwright/presentation/presentation.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bigbluebutton-tests/playwright/presentation/presentation.spec.js b/bigbluebutton-tests/playwright/presentation/presentation.spec.js index 3bdb39e9c5..a2079bc5a8 100644 --- a/bigbluebutton-tests/playwright/presentation/presentation.spec.js +++ b/bigbluebutton-tests/playwright/presentation/presentation.spec.js @@ -47,13 +47,13 @@ test.describe.parallel('Presentation', () => { await presentation.presentationFullscreen(); }); - test.only('Presentation snapshot @ci @flaky', async ({ browser, context, page }, testInfo) => { + test('Presentation snapshot @ci @flaky', async ({ browser, context, page }, testInfo) => { const presentation = new Presentation(browser, context); await presentation.initPages(page); await presentation.presentationSnapshot(testInfo); }); - test.only('Hide Presentation Toolbar @ci @flaky', async ({ browser, context, page }) => { + test('Hide Presentation Toolbar @ci @flaky', async ({ browser, context, page }) => { const presentation = new Presentation(browser, context); await presentation.initPages(page); await presentation.hidePresentationToolbar(); From ef0c48d46eec168126f65ee885cf9f9217a3c505 Mon Sep 17 00:00:00 2001 From: Gabriel Porfirio Date: Mon, 29 Jan 2024 09:55:11 -0300 Subject: [PATCH 0269/1039] removing polling changes, sending in another pr --- bigbluebutton-tests/playwright/polling/poll.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/bigbluebutton-tests/playwright/polling/poll.js b/bigbluebutton-tests/playwright/polling/poll.js index c753c1da99..2cce8f2272 100644 --- a/bigbluebutton-tests/playwright/polling/poll.js +++ b/bigbluebutton-tests/playwright/polling/poll.js @@ -139,8 +139,6 @@ class Polling extends MultiUsers { await this.modPage.type(e.pollQuestionArea, 'Test'); await this.modPage.waitAndClick(e.addPollItem); await this.modPage.type(e.pollOptionItem, 'test1'); - await this.modPage.waitAndClick(e.addPollItem); - await this.modPage.type(e.pollOptionItem2, 'test2'); await this.modPage.waitAndClick(e.startPoll); await this.userPage.hasElement(e.pollingContainer); From 431511b8dac0f98d31f82ea67f0997b6479c26ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Mon, 29 Jan 2024 09:49:40 -0300 Subject: [PATCH 0270/1039] migrate toggleVoice --- .../imports/api/voice-users/server/index.js | 1 - .../imports/api/voice-users/server/methods.js | 6 -- .../voice-users/server/methods/muteToggle.js | 66 ------------------- .../imports/ui/components/app/component.jsx | 3 +- .../imports/ui/components/app/container.jsx | 3 + .../buttons/LiveSelection.tsx | 2 +- .../buttons/muteToggle.tsx | 6 +- .../input-stream-live-selector/service.ts | 9 ++- .../audio-graphql/hooks/useToggleVoice.ts | 28 ++++++++ .../audio/audio-graphql/mutations.ts | 14 ++++ .../components/audio/audio-graphql/queries.ts | 21 ++++++ .../imports/ui/components/audio/container.jsx | 7 +- .../imports/ui/components/audio/service.js | 25 ++++--- .../ui/components/breakout-room/container.jsx | 6 +- .../talking-indicator/component.tsx | 8 ++- .../talking-indicator/service.ts | 11 +++- .../ui/components/user-list/service.js | 6 +- .../user-actions/component.tsx | 6 +- .../user-actions/service.ts | 7 +- 19 files changed, 125 insertions(+), 110 deletions(-) delete mode 100755 bigbluebutton-html5/imports/api/voice-users/server/methods.js delete mode 100644 bigbluebutton-html5/imports/api/voice-users/server/methods/muteToggle.js create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/hooks/useToggleVoice.ts create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/mutations.ts create mode 100644 bigbluebutton-html5/imports/ui/components/audio/audio-graphql/queries.ts diff --git a/bigbluebutton-html5/imports/api/voice-users/server/index.js b/bigbluebutton-html5/imports/api/voice-users/server/index.js index af6a7345b5..f993f38e5b 100644 --- a/bigbluebutton-html5/imports/api/voice-users/server/index.js +++ b/bigbluebutton-html5/imports/api/voice-users/server/index.js @@ -1,3 +1,2 @@ import './eventHandlers'; import './publishers'; -import './methods'; diff --git a/bigbluebutton-html5/imports/api/voice-users/server/methods.js b/bigbluebutton-html5/imports/api/voice-users/server/methods.js deleted file mode 100755 index 637a3798a9..0000000000 --- a/bigbluebutton-html5/imports/api/voice-users/server/methods.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import muteToggle from './methods/muteToggle'; - -Meteor.methods({ - toggleVoice: muteToggle, -}); diff --git a/bigbluebutton-html5/imports/api/voice-users/server/methods/muteToggle.js b/bigbluebutton-html5/imports/api/voice-users/server/methods/muteToggle.js deleted file mode 100644 index 7c8101a079..0000000000 --- a/bigbluebutton-html5/imports/api/voice-users/server/methods/muteToggle.js +++ /dev/null @@ -1,66 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { extractCredentials } from '/imports/api/common/server/helpers'; -import RedisPubSub from '/imports/startup/server/redis'; -import Users from '/imports/api/users'; -import VoiceUsers from '/imports/api/voice-users'; -import Meetings from '/imports/api/meetings'; -import Logger from '/imports/startup/server/logger'; -import { check } from 'meteor/check'; - -export default async function muteToggle(uId, toggle) { - try { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'MuteUserCmdMsg'; - - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - - const userToMute = uId || requesterUserId; - - const requester = await Users.findOneAsync({ - meetingId, - userId: requesterUserId, - }); - - const voiceUser = await VoiceUsers.findOneAsync({ - intId: userToMute, - meetingId, - }); - - if (!requester || !voiceUser) return; - - const { listenOnly, muted } = voiceUser; - if (listenOnly) return; - - // if allowModsToUnmuteUsers is false, users will be kicked out for attempting to unmute others - if (requesterUserId !== userToMute && muted) { - const meeting = await Meetings.findOneAsync({ meetingId }, - { fields: { 'usersProp.allowModsToUnmuteUsers': 1 } }); - if (meeting.usersProp && !meeting.usersProp.allowModsToUnmuteUsers) { - Logger.warn(`Attempted unmuting by another user meetingId:${meetingId} requester: ${requesterUserId} userId: ${userToMute}`); - return; - } - } - - let _muted; - - if ((toggle === undefined) || (toggle === null)) { - _muted = !muted; - } else { - _muted = !!toggle; - } - - const payload = { - userId: userToMute, - mutedBy: requesterUserId, - mute: _muted, - }; - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); - } catch (err) { - Logger.error(`Exception while invoking method muteToggle ${err.stack}`); - } -} diff --git a/bigbluebutton-html5/imports/ui/components/app/component.jsx b/bigbluebutton-html5/imports/ui/components/app/component.jsx index 9e90a61982..9b973d41d6 100644 --- a/bigbluebutton-html5/imports/ui/components/app/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/app/component.jsx @@ -174,6 +174,7 @@ class App extends Component { layoutContextDispatch, isRTL, setMobileUser, + toggleVoice, } = this.props; const { browserName } = browserInfo; const { osName } = deviceInfo; @@ -217,7 +218,7 @@ class App extends Component { if (CONFIRMATION_ON_LEAVE) { window.onbeforeunload = (event) => { - AudioService.muteMicrophone(); + AudioService.muteMicrophone(toggleVoice); event.stopImmediatePropagation(); event.preventDefault(); // eslint-disable-next-line no-param-reassign diff --git a/bigbluebutton-html5/imports/ui/components/app/container.jsx b/bigbluebutton-html5/imports/ui/components/app/container.jsx index 5fcd0183aa..449135f338 100755 --- a/bigbluebutton-html5/imports/ui/components/app/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/app/container.jsx @@ -34,6 +34,7 @@ import { } from './service'; import App from './component'; +import useToggleVoice from '../audio/audio-graphql/hooks/useToggleVoice'; const CUSTOM_STYLE_URL = Meteor.settings.public.app.customStyleUrl; @@ -90,6 +91,7 @@ const AppContainer = (props) => { const [setMobileFlag] = useMutation(SET_MOBILE_FLAG); const [setSyncWithPresenterLayout] = useMutation(SET_SYNC_WITH_PRESENTER_LAYOUT); const [setMeetingLayoutProps] = useMutation(SET_LAYOUT_PROPS); + const toggleVoice = useToggleVoice(); const setMobileUser = (mobile) => { setMobileFlag({ @@ -238,6 +240,7 @@ const AppContainer = (props) => { shouldShowSharedNotes, shouldShowPresentation, setMobileUser, + toggleVoice, }} {...otherProps} /> diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/LiveSelection.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/LiveSelection.tsx index 518f28b5ec..09d70cc4ba 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/LiveSelection.tsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/LiveSelection.tsx @@ -63,7 +63,7 @@ interface MuteToggleProps { muted: boolean; disabled: boolean; isAudioLocked: boolean; - toggleMuteMicrophone: (muted: boolean) => void; + toggleMuteMicrophone: (muted: boolean, toggleVoice: (userId?: string | null, muted?: boolean | null) => void) => void; } interface LiveSelectionProps extends MuteToggleProps { diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/muteToggle.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/muteToggle.tsx index e378e31ded..376ac1bbeb 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/muteToggle.tsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/muteToggle.tsx @@ -3,6 +3,7 @@ import { defineMessages, useIntl } from 'react-intl'; import Styled from '../styles'; import { useShortcut } from '/imports/ui/core/hooks/useShortcut'; import Settings from '/imports/ui/services/settings'; +import useToggleVoice from '../../../hooks/useToggleVoice'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - temporary while settings are still in .js @@ -24,7 +25,7 @@ interface MuteToggleProps { muted: boolean; disabled: boolean; isAudioLocked: boolean; - toggleMuteMicrophone: (muted: boolean) => void; + toggleMuteMicrophone: (muted: boolean, toggleVoice: (userId?: string | null, muted?: boolean | null) => void) => void; } export const Mutetoggle: React.FC = ({ @@ -36,12 +37,13 @@ export const Mutetoggle: React.FC = ({ }) => { const intl = useIntl(); const toggleMuteShourtcut = useShortcut('toggleMute'); + const toggleVoice = useToggleVoice(); const label = muted ? intl.formatMessage(intlMessages.unmuteAudio) : intl.formatMessage(intlMessages.muteAudio); const onClickCallback = (e: React.MouseEvent) => { e.stopPropagation(); - toggleMuteMicrophone(muted); + toggleMuteMicrophone(muted, toggleVoice); }; return ( // eslint-disable-next-line jsx-a11y/no-access-key diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service.ts index 93dceafa96..c0daa31c60 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service.ts +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service.ts @@ -39,7 +39,10 @@ export const handleLeaveAudio = (meetingIsBreakout: boolean) => { ); }; -export const toggleMuteMicrophone = (muted: boolean) => { +export const toggleMuteMicrophone = ( + muted: boolean, + toggleVoice: (userId?: string | null, muted?: boolean | null) => void, +) => { Storage.setItem(MUTED_KEY, !muted); if (muted) { @@ -50,7 +53,7 @@ export const toggleMuteMicrophone = (muted: boolean) => { }, 'microphone unmuted by user', ); - makeCall('toggleVoice'); + toggleVoice(); } else { logger.info( { @@ -59,7 +62,7 @@ export const toggleMuteMicrophone = (muted: boolean) => { }, 'microphone muted by user', ); - makeCall('toggleVoice'); + toggleVoice(); } }; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/hooks/useToggleVoice.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/hooks/useToggleVoice.ts new file mode 100644 index 0000000000..adcdfae52c --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/hooks/useToggleVoice.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; +import { useMutation, useSubscription } from '@apollo/client'; +import Auth from '/imports/ui/services/auth'; +import { USER_SET_MUTED } from '../mutations'; +import { USER_MUTED, UserMutedResponse } from '../queries'; + +const useToggleVoice = () => { + const [userSetMuted] = useMutation(USER_SET_MUTED); + const { data: userMutedData } = useSubscription(USER_MUTED); + + const toggleVoice = async (userId?: string | null, muted?: boolean | null) => { + let shouldMute = muted; + const userToMute = userId ?? Auth.userID; + + if (muted === undefined || muted === null) { + const { user_voice } = userMutedData || {}; + const userData = user_voice && user_voice.find((u) => u.userId === userToMute); + if (!userData) return; + shouldMute = !userData.muted; + } + + userSetMuted({ variables: { muted: shouldMute, userId: userToMute } }); + }; + + return useCallback(toggleVoice, [userMutedData]); +}; + +export default useToggleVoice; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/mutations.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/mutations.ts new file mode 100644 index 0000000000..79e82332cb --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/mutations.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const USER_SET_MUTED = gql` + mutation UserSetMuted($userId: String, $muted: Boolean!) { + userSetMuted( + userId: $userId, + muted: $muted + ) + } +`; + +export default { + USER_SET_MUTED, +}; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/queries.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/queries.ts new file mode 100644 index 0000000000..cb124e7484 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/queries.ts @@ -0,0 +1,21 @@ +import { gql } from '@apollo/client'; + +export interface UserMutedResponse { + user_voice: Array<{ + muted: boolean; + userId: string; + }>; +} + +export const USER_MUTED = gql` + subscription UserMuted { + user_voice { + muted + userId + } + } +`; + +export default { + USER_MUTED, +}; diff --git a/bigbluebutton-html5/imports/ui/components/audio/container.jsx b/bigbluebutton-html5/imports/ui/components/audio/container.jsx index 86a2be70c5..46c2210baf 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/container.jsx @@ -22,6 +22,7 @@ import { import Service from './service'; import AudioModalContainer from './audio-modal/container'; import Settings from '/imports/ui/services/settings'; +import useToggleVoice from './audio-graphql/hooks/useToggleVoice'; const APP_CONFIG = Meteor.settings.public.app; const KURENTO_CONFIG = Meteor.settings.public.kurento; @@ -201,11 +202,13 @@ export default lockContextContainer(injectIntl(withTracker(({ intl, userLocks, i setVideoPreviewModalIsOpen(true); }; + const toggleVoice = useToggleVoice(); + if (Service.isConnected() && !Service.isListenOnly()) { Service.updateAudioConstraints(microphoneConstraints); if (userMic && !Service.isMuted()) { - Service.toggleMuteMicrophone(); + Service.toggleMuteMicrophone(toggleVoice); notify(intl.formatMessage(intlMessages.reconectingAsListener), 'info', 'volume_level_2'); } } @@ -244,7 +247,7 @@ export default lockContextContainer(injectIntl(withTracker(({ intl, userLocks, i isAudioModalOpen, setAudioModalIsOpen, init: async () => { - await Service.init(messages, intl); + await Service.init(messages, intl, toggleVoice); const enableVideo = getFromUserSettings('bbb_enable_video', KURENTO_CONFIG.enableVideo); const autoShareWebcam = getFromUserSettings('bbb_auto_share_webcam', KURENTO_CONFIG.autoShareWebcam); if ((!autoJoin || didMountAutoJoin)) { diff --git a/bigbluebutton-html5/imports/ui/components/audio/service.js b/bigbluebutton-html5/imports/ui/components/audio/service.js index 559b91517f..76c6be665b 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/service.js +++ b/bigbluebutton-html5/imports/ui/components/audio/service.js @@ -4,7 +4,6 @@ import { throttle } from '/imports/utils/throttle'; import { debounce } from '/imports/utils/debounce'; import AudioManager from '/imports/ui/services/audio-manager'; import Meetings from '/imports/api/meetings'; -import { makeCall } from '/imports/ui/services/api'; import VoiceUsers from '/imports/api/voice-users'; import logger from '/imports/startup/client/logger'; import Storage from '../../services/storage/session'; @@ -19,7 +18,7 @@ const { const MUTED_KEY = 'muted'; -const recoverMicState = () => { +const recoverMicState = (toggleVoice) => { const muted = Storage.getItem(MUTED_KEY); if ((muted === undefined) || (muted === null)) { @@ -29,24 +28,24 @@ const recoverMicState = () => { logger.debug({ logCode: 'audio_recover_mic_state', }, `Audio recover previous mic state: muted = ${muted}`); - makeCall('toggleVoice', null, muted); + toggleVoice(null, muted); }; -const audioEventHandler = (event) => { +const audioEventHandler = (toggleVoice) => (event) => { if (!event) { return; } switch (event.name) { case 'started': - if (!event.isListenOnly) recoverMicState(); + if (!event.isListenOnly) recoverMicState(toggleVoice); break; default: break; } }; -const init = (messages, intl) => { +const init = (messages, intl, toggleVoice) => { AudioManager.setAudioMessages(messages, intl); if (AudioManager.initialized) return Promise.resolve(false); const meetingId = Auth.meetingID; @@ -69,10 +68,10 @@ const init = (messages, intl) => { microphoneLockEnforced, }; - return AudioManager.init(userData, audioEventHandler); + return AudioManager.init(userData, audioEventHandler(toggleVoice)); }; -const muteMicrophone = () => { +const muteMicrophone = (toggleVoice) => { const user = VoiceUsers.findOne({ meetingId: Auth.meetingID, intId: Auth.userID, }, { fields: { muted: 1 } }); @@ -83,7 +82,7 @@ const muteMicrophone = () => { extraInfo: { logType: 'user_action' }, }, 'User wants to leave conference. Microphone muted'); AudioManager.setSenderTrackEnabled(false); - makeCall('toggleVoice'); + toggleVoice(); } }; @@ -93,7 +92,7 @@ const isVoiceUser = () => { return voiceUser ? voiceUser.joined : false; }; -const toggleMuteMicrophone = throttle(() => { +const toggleMuteMicrophone = throttle((toggleVoice) => { const user = VoiceUsers.findOne({ meetingId: Auth.meetingID, intId: Auth.userID, }, { fields: { muted: 1 } }); @@ -105,13 +104,13 @@ const toggleMuteMicrophone = throttle(() => { logCode: 'audiomanager_unmute_audio', extraInfo: { logType: 'user_action' }, }, 'microphone unmuted by user'); - makeCall('toggleVoice'); + toggleVoice(); } else { logger.info({ logCode: 'audiomanager_mute_audio', extraInfo: { logType: 'user_action' }, }, 'microphone muted by user'); - makeCall('toggleVoice'); + toggleVoice(); } }, TOGGLE_MUTE_THROTTLE_TIME); @@ -160,7 +159,7 @@ export default { updateAudioConstraints: (constraints) => AudioManager.updateAudioConstraints(constraints), recoverMicState, - muteMicrophone: () => muteMicrophone(), + muteMicrophone: (toggleVoice) => muteMicrophone(toggleVoice), isReconnecting: () => AudioManager.isReconnecting, setBreakoutAudioTransferStatus: (status) => AudioManager .setBreakoutAudioTransferStatus(status), diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx b/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx index 5786b86423..3733c94633 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx @@ -10,7 +10,6 @@ import { didUserSelectedMicrophone, didUserSelectedListenOnly, } from '/imports/ui/components/audio/audio-modal/service'; -import { makeCall } from '/imports/ui/services/api'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; import { BREAKOUT_ROOM_END_ALL, @@ -20,6 +19,7 @@ import { } from './mutations'; import logger from '/imports/startup/client/logger'; import { CAMERA_BROADCAST_STOP } from '../video-provider/mutations'; +import useToggleVoice from '../audio/audio-graphql/hooks/useToggleVoice'; const BreakoutContainer = (props) => { const layoutContextDispatch = layoutDispatch(); @@ -97,6 +97,8 @@ export default withTracker((props) => { getBreakoutAudioTransferStatus, } = AudioService; + const toggleVoice = useToggleVoice(); + const logUserCouldNotRejoinAudio = () => { logger.warn({ logCode: 'mainroom_audio_rejoin', @@ -107,7 +109,7 @@ export default withTracker((props) => { const rejoinAudio = () => { if (didUserSelectedMicrophone()) { AudioManager.joinMicrophone().then(() => { - makeCall('toggleVoice', null, true).catch(() => { + toggleVoice(null, true).catch(() => { AudioManager.forceExitAudio(); logUserCouldNotRejoinAudio(); }); diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/nav-bar-graphql/talking-indicator/component.tsx b/bigbluebutton-html5/imports/ui/components/nav-bar/nav-bar-graphql/talking-indicator/component.tsx index 0a5dffdd45..4658d0fa27 100644 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/nav-bar-graphql/talking-indicator/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/nav-bar-graphql/talking-indicator/component.tsx @@ -13,6 +13,7 @@ import Styled from './styles'; import { User } from '/imports/ui/Types/user'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; import { muteUser } from './service'; +import useToggleVoice from '../../../audio/audio-graphql/hooks/useToggleVoice'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - temporary, while meteor exists in the project @@ -53,6 +54,7 @@ interface TalkingIndicatorProps { isBreakout: boolean; moreThanMaxIndicators: boolean; isModerator: boolean; + toggleVoice: (userId?: string | null, muted?: boolean | null) => void; } const TalkingIndicator: React.FC = ({ @@ -60,6 +62,7 @@ const TalkingIndicator: React.FC = ({ isBreakout, moreThanMaxIndicators, isModerator, + toggleVoice, }) => { const intl = useIntl(); const talkingElements = useMemo(() => talkingUsers.map((talkingUser: Partial) => { @@ -97,7 +100,7 @@ const TalkingIndicator: React.FC = ({ onClick={() => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - call signature is misse due the function being wrapped - muteUser(talkingUser.userId, muted, isBreakout, isModerator); + muteUser(talkingUser.userId, muted, isBreakout, isModerator, toggleVoice); }} label={name} tooltipLabel={!muted && isModerator @@ -194,6 +197,8 @@ const TalkingIndicatorContainer: React.FC = (() => { error: isBreakoutError, } = useSubscription(MEETING_ISBREAKOUT_SUBSCRIPTION); + const toggleVoice = useToggleVoice(); + if (talkingIndicatorLoading || isBreakoutLoading) return null; if (talkingIndicatorError || isBreakoutError) { @@ -214,6 +219,7 @@ const TalkingIndicatorContainer: React.FC = (() => { isBreakout={isBreakout} moreThanMaxIndicators={talkingUsers.length >= TALKING_INDICATORS_MAX} isModerator={currentUser?.isModerator ?? false} + toggleVoice={toggleVoice} /> ); }; diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/nav-bar-graphql/talking-indicator/service.ts b/bigbluebutton-html5/imports/ui/components/nav-bar/nav-bar-graphql/talking-indicator/service.ts index f79ceb581c..2b9cfbd055 100644 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/nav-bar-graphql/talking-indicator/service.ts +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/nav-bar-graphql/talking-indicator/service.ts @@ -1,13 +1,18 @@ import { debounce } from 'radash'; -import { makeCall } from '/imports/ui/services/api'; const TALKING_INDICATOR_MUTE_INTERVAL = 500; export const muteUser = debounce( { delay: TALKING_INDICATOR_MUTE_INTERVAL }, - (id: string, muted: boolean | undefined, isBreakout: boolean, isModerator: boolean) => { + ( + id: string, + muted: boolean | undefined, + isBreakout: boolean, + isModerator: boolean, + toggleVoice: (userId?: string | null, muted?: string | null) => void, + ) => { if (!isModerator || isBreakout || muted) return null; - makeCall('toggleVoice', id); + toggleVoice(id); return null; }, ); diff --git a/bigbluebutton-html5/imports/ui/components/user-list/service.js b/bigbluebutton-html5/imports/ui/components/user-list/service.js index 358eddf27e..448586038e 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/service.js +++ b/bigbluebutton-html5/imports/ui/components/user-list/service.js @@ -480,11 +480,11 @@ const normalizeEmojiName = (emoji) => ( emoji in EMOJI_STATUSES ? EMOJI_STATUSES[emoji] : emoji ); -const toggleVoice = (userId) => { +const toggleVoice = (userId, voiceToggle) => { if (userId === Auth.userID) { - AudioService.toggleMuteMicrophone(); + AudioService.toggleMuteMicrophone(voiceToggle); } else { - makeCall('toggleVoice', userId); + voiceToggle(userId); logger.info({ logCode: 'usermenu_option_mute_toggle_audio', extraInfo: { logType: 'moderator_action', userId }, diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/component.tsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/component.tsx index bb48ad270c..0bce0f3ca7 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/component.tsx @@ -41,6 +41,7 @@ import Styled from './styles'; import { useMutation, useLazyQuery } from '@apollo/client'; import { CURRENT_PAGE_WRITERS_QUERY } from '/imports/ui/components/whiteboard/queries'; import { PRESENTATION_SET_WRITERS } from '/imports/ui/components/presentation/mutations'; +import useToggleVoice from '/imports/ui/components/audio/audio-graphql/hooks/useToggleVoice'; interface UserActionsProps { user: User; @@ -215,6 +216,7 @@ const UserActions: React.FC = ({ const [presentationSetWriters] = useMutation(PRESENTATION_SET_WRITERS); const [getWriters, { data: usersData }] = useLazyQuery(CURRENT_PAGE_WRITERS_QUERY, { fetchPolicy: 'no-cache' }); const writers = usersData?.pres_page_writers || null; + const voiceToggle = useToggleVoice(); // users will only be fetched when getWriters is called useEffect(() => { @@ -405,7 +407,7 @@ const UserActions: React.FC = ({ key: 'mute', label: intl.formatMessage(messages.MuteUserAudioLabel), onClick: () => { - toggleVoice(user.userId); + toggleVoice(user.userId, voiceToggle); setSelected(false); }, icon: 'mute', @@ -417,7 +419,7 @@ const UserActions: React.FC = ({ key: 'unmute', label: intl.formatMessage(messages.UnmuteUserAudioLabel), onClick: () => { - toggleVoice(user.userId); + toggleVoice(user.userId, voiceToggle); setSelected(false); }, icon: 'unmute', diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/service.ts b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/service.ts index 1ba270ce88..0c621398e9 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/service.ts +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/service.ts @@ -5,7 +5,6 @@ import { } from '/imports/ui/Types/meeting'; import Auth from '/imports/ui/services/auth'; import { EMOJI_STATUSES } from '/imports/utils/statuses'; -import { makeCall } from '/imports/ui/services/api'; import AudioService from '/imports/ui/components/audio/service'; import logger from '/imports/startup/client/logger'; @@ -128,11 +127,11 @@ export const isVideoPinEnabledForCurrentUser = ( // so this code is duplicated from the old userlist service // session for chats the current user started -export const toggleVoice = (userId: string) => { +export const toggleVoice = (userId: string, voiceToggle: (userId?: string | null, muted?: boolean | null) => void) => { if (userId === Auth.userID) { - AudioService.toggleMuteMicrophone(); + AudioService.toggleMuteMicrophone(voiceToggle); } else { - makeCall('toggleVoice', userId); + voiceToggle(userId); logger.info({ logCode: 'usermenu_option_mute_toggle_audio', extraInfo: { logType: 'moderator_action', userId }, From b5349aacb45ca41a7203802a648dfdb9728332a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= Date: Mon, 29 Jan 2024 11:52:44 -0300 Subject: [PATCH 0271/1039] Corrections --- .../page/chat-message/component.tsx | 44 +++++++++++-------- bigbluebutton-html5/public/locales/en.json | 2 +- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx index 54b0b2768d..82acf789d7 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/component.tsx @@ -110,9 +110,12 @@ const ChatMesssage: React.FC = ({ || lastSenderPreviousPage) === message?.user?.userId; const isSystemSender = message.messageType === ChatMessageType.BREAKOUT_ROOM; const dateTime = new Date(message?.createdAt); - - const msgTime = dateTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }); - const clearMessage = `${intl.formatMessage(intlMessages.chatClear)} at ${msgTime}`; + const formattedTime = intl.formatTime(dateTime, { + hour: 'numeric', + minute: 'numeric', + }); + const msgTime = formattedTime; + const clearMessage = `${intl.formatMessage(intlMessages.chatClear, { 0: msgTime })}`; const messageContent: { name: string, @@ -204,24 +207,27 @@ const ChatMesssage: React.FC = ({ }, []); return ( - {(!message?.user || !sameSender) && ( - - {!message.user || message.user?.avatar.length === 0 ? messageContent.name.toLowerCase().slice(0, 2) || '' : ''} - + {(!message?.user || !sameSender) && + (message.messageType !== ChatMessageType.USER_AWAY_STATUS_MSG + && message.messageType !== ChatMessageType.CHAT_CLEAR) && ( + + {!message.user || message.user?.avatar.length === 0 ? messageContent.name.toLowerCase().slice(0, 2) || '' : ''} + )} - {!ChatMessageType.USER_AWAY_STATUS_MSG ? ( - - ) : null } + {message.messageType !== ChatMessageType.USER_AWAY_STATUS_MSG + && message.messageType !== ChatMessageType.CHAT_CLEAR && ( + + )} {messageContent.component} diff --git a/bigbluebutton-html5/public/locales/en.json b/bigbluebutton-html5/public/locales/en.json index 11871d2242..4757124e0b 100755 --- a/bigbluebutton-html5/public/locales/en.json +++ b/bigbluebutton-html5/public/locales/en.json @@ -28,7 +28,7 @@ "app.chat.emptyLogLabel": "Chat log empty", "app.chat.away": "Is away", "app.chat.notAway": "Is not away anymore", - "app.chat.clearPublicChatMessage": "The public chat history was cleared by a moderator", + "app.chat.clearPublicChatMessage": "The public chat history was cleared by a moderator at {0}", "app.chat.multi.typing": "Multiple users are typing", "app.chat.someone.typing": "Someone is typing", "app.chat.one.typing": "{0} is typing", From e28408bca285b3a5532d9bfe37b0e40654551e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Mon, 29 Jan 2024 13:27:30 -0300 Subject: [PATCH 0272/1039] remove unused audio captions code --- .../imports/api/audio-captions/index.js | 9 - .../audio-captions/server/eventHandlers.js | 4 - .../server/handlers/transcriptUpdated.js | 12 - .../api/audio-captions/server/index.js | 2 - .../server/modifiers/clearAudioCaptions.js | 26 -- .../server/modifiers/setTranscript.js | 30 -- .../api/audio-captions/server/publishers.js | 26 -- .../server/modifiers/meetingHasEnded.js | 2 - .../ui/components/actions-bar/component.jsx | 2 +- .../imports/ui/components/app/container.jsx | 2 +- .../audio/captions/button/component.jsx | 259 ------------------ .../audio/captions/button/container.jsx | 28 -- .../audio/captions/button/styles.js | 61 ----- .../audio/captions/live/component.jsx | 104 ------- .../audio/captions/live/container.jsx | 21 -- .../audio/captions/live/user/component.jsx | 39 --- .../audio/captions/live/user/container.jsx | 44 --- .../ui/components/audio/captions/service.js | 30 -- .../nav-bar/options-dropdown/container.jsx | 15 +- .../ui/components/subscriptions/component.jsx | 1 - .../private/config/settings.yml | 2 +- bigbluebutton-html5/server/main.js | 1 - 22 files changed, 16 insertions(+), 704 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/audio-captions/index.js delete mode 100644 bigbluebutton-html5/imports/api/audio-captions/server/eventHandlers.js delete mode 100644 bigbluebutton-html5/imports/api/audio-captions/server/handlers/transcriptUpdated.js delete mode 100644 bigbluebutton-html5/imports/api/audio-captions/server/index.js delete mode 100644 bigbluebutton-html5/imports/api/audio-captions/server/modifiers/clearAudioCaptions.js delete mode 100644 bigbluebutton-html5/imports/api/audio-captions/server/modifiers/setTranscript.js delete mode 100644 bigbluebutton-html5/imports/api/audio-captions/server/publishers.js delete mode 100644 bigbluebutton-html5/imports/ui/components/audio/captions/button/component.jsx delete mode 100644 bigbluebutton-html5/imports/ui/components/audio/captions/button/container.jsx delete mode 100644 bigbluebutton-html5/imports/ui/components/audio/captions/button/styles.js delete mode 100644 bigbluebutton-html5/imports/ui/components/audio/captions/live/component.jsx delete mode 100644 bigbluebutton-html5/imports/ui/components/audio/captions/live/container.jsx delete mode 100644 bigbluebutton-html5/imports/ui/components/audio/captions/live/user/component.jsx delete mode 100644 bigbluebutton-html5/imports/ui/components/audio/captions/live/user/container.jsx diff --git a/bigbluebutton-html5/imports/api/audio-captions/index.js b/bigbluebutton-html5/imports/api/audio-captions/index.js deleted file mode 100644 index cf10470261..0000000000 --- a/bigbluebutton-html5/imports/api/audio-captions/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -const AudioCaptions = new Mongo.Collection('audio-captions'); - -if (Meteor.isServer) { - AudioCaptions.createIndexAsync({ meetingId: 1 }); -} - -export default AudioCaptions; diff --git a/bigbluebutton-html5/imports/api/audio-captions/server/eventHandlers.js b/bigbluebutton-html5/imports/api/audio-captions/server/eventHandlers.js deleted file mode 100644 index 9a8e2f1a96..0000000000 --- a/bigbluebutton-html5/imports/api/audio-captions/server/eventHandlers.js +++ /dev/null @@ -1,4 +0,0 @@ -import RedisPubSub from '/imports/startup/server/redis'; -import handleTranscriptUpdated from '/imports/api/audio-captions/server/handlers/transcriptUpdated'; - -RedisPubSub.on('TranscriptUpdatedEvtMsg', handleTranscriptUpdated); diff --git a/bigbluebutton-html5/imports/api/audio-captions/server/handlers/transcriptUpdated.js b/bigbluebutton-html5/imports/api/audio-captions/server/handlers/transcriptUpdated.js deleted file mode 100644 index d8e66c6cfc..0000000000 --- a/bigbluebutton-html5/imports/api/audio-captions/server/handlers/transcriptUpdated.js +++ /dev/null @@ -1,12 +0,0 @@ -import setTranscript from '/imports/api/audio-captions/server/modifiers/setTranscript'; - -export default async function transcriptUpdated({ header, body }) { - const { meetingId } = header; - - const { - transcriptId, - transcript, - } = body; - - await setTranscript(meetingId, transcriptId, transcript); -} diff --git a/bigbluebutton-html5/imports/api/audio-captions/server/index.js b/bigbluebutton-html5/imports/api/audio-captions/server/index.js deleted file mode 100644 index f993f38e5b..0000000000 --- a/bigbluebutton-html5/imports/api/audio-captions/server/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import './eventHandlers'; -import './publishers'; diff --git a/bigbluebutton-html5/imports/api/audio-captions/server/modifiers/clearAudioCaptions.js b/bigbluebutton-html5/imports/api/audio-captions/server/modifiers/clearAudioCaptions.js deleted file mode 100644 index cd97c33f14..0000000000 --- a/bigbluebutton-html5/imports/api/audio-captions/server/modifiers/clearAudioCaptions.js +++ /dev/null @@ -1,26 +0,0 @@ -import AudioCaptions from '/imports/api/audio-captions'; -import Logger from '/imports/startup/server/logger'; - -export default async function clearAudioCaptions(meetingId) { - if (meetingId) { - try { - const numberAffected = await AudioCaptions.removeAsync({ meetingId }); - - if (numberAffected) { - Logger.info(`Cleared AudioCaptions (${meetingId})`); - } - } catch (err) { - Logger.error(`Error on clearing audio captions (${meetingId}). ${err}`); - } - } else { - try { - const numberAffected = await AudioCaptions.removeAsync({}); - - if (numberAffected) { - Logger.info('Cleared AudioCaptions (all)'); - } - } catch (err) { - Logger.error(`Error on clearing audio captions (all). ${err}`); - } - } -} diff --git a/bigbluebutton-html5/imports/api/audio-captions/server/modifiers/setTranscript.js b/bigbluebutton-html5/imports/api/audio-captions/server/modifiers/setTranscript.js deleted file mode 100644 index d5ef7b7973..0000000000 --- a/bigbluebutton-html5/imports/api/audio-captions/server/modifiers/setTranscript.js +++ /dev/null @@ -1,30 +0,0 @@ -import { check } from 'meteor/check'; -import AudioCaptions from '/imports/api/audio-captions'; -import Logger from '/imports/startup/server/logger'; - -export default async function setTranscript(meetingId, transcriptId, transcript) { - try { - check(meetingId, String); - check(transcriptId, String); - check(transcript, String); - - const selector = { meetingId }; - - const modifier = { - $set: { - transcriptId, - transcript, - }, - }; - - const numberAffected = await AudioCaptions.upsertAsync(selector, modifier); - - if (numberAffected) { - Logger.debug(`Set transcriptId=${transcriptId} transcript=${transcript} meeting=${meetingId}`); - } else { - Logger.debug(`Upserted transcriptId=${transcriptId} transcript=${transcript} meeting=${meetingId}`); - } - } catch (err) { - Logger.error(`Setting audio captions transcript to the collection: ${err}`); - } -} diff --git a/bigbluebutton-html5/imports/api/audio-captions/server/publishers.js b/bigbluebutton-html5/imports/api/audio-captions/server/publishers.js deleted file mode 100644 index 17d4632fea..0000000000 --- a/bigbluebutton-html5/imports/api/audio-captions/server/publishers.js +++ /dev/null @@ -1,26 +0,0 @@ -import AudioCaptions from '/imports/api/audio-captions'; -import { Meteor } from 'meteor/meteor'; -import Logger from '/imports/startup/server/logger'; -import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; - -async function audioCaptions() { - const tokenValidation = await AuthTokenValidation - .findOneAsync({ connectionId: this.connection.id }); - - if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { - Logger.warn(`Publishing AudioCaptions was requested by unauth connection ${this.connection.id}`); - return AudioCaptions.find({ meetingId: '' }); - } - - const { meetingId, userId } = tokenValidation; - Logger.debug('Publishing AudioCaptions', { meetingId, requestedBy: userId }); - - return AudioCaptions.find({ meetingId }); -} - -function publish(...args) { - const boundAudioCaptions = audioCaptions.bind(this); - return boundAudioCaptions(...args); -} - -Meteor.publish('audio-captions', publish); diff --git a/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js b/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js index 7ea6d69e7c..131c7b0db1 100755 --- a/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js +++ b/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js @@ -13,7 +13,6 @@ import clearVoiceUsers from '/imports/api/voice-users/server/modifiers/clearVoic import clearUserInfo from '/imports/api/users-infos/server/modifiers/clearUserInfo'; import clearScreenshare from '/imports/api/screenshare/server/modifiers/clearScreenshare'; import clearTimer from '/imports/api/timer/server/modifiers/clearTimer'; -import clearAudioCaptions from '/imports/api/audio-captions/server/modifiers/clearAudioCaptions'; import clearMeetingTimeRemaining from '/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining'; import clearLocalSettings from '/imports/api/local-settings/server/modifiers/clearLocalSettings'; import clearRecordMeeting from './clearRecordMeeting'; @@ -42,7 +41,6 @@ export default async function meetingHasEnded(meetingId) { clearVoiceUsers(meetingId), clearUserInfo(meetingId), clearTimer(meetingId), - clearAudioCaptions(meetingId), clearLocalSettings(meetingId), clearMeetingTimeRemaining(meetingId), clearRecordMeeting(meetingId), diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx index c3506db1f4..20ddbc4263 100755 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx @@ -4,7 +4,7 @@ import deviceInfo from '/imports/utils/deviceInfo'; import { ActionsBarItemType, ActionsBarPosition } from 'bigbluebutton-html-plugin-sdk/dist/cjs/extensible-areas/actions-bar-item/enums'; import Styled from './styles'; import ActionsDropdown from './actions-dropdown/container'; -import AudioCaptionsButtonContainer from '/imports/ui/components/audio/captions/button/container'; +import AudioCaptionsButtonContainer from '/imports/ui/components/audio/audio-graphql/audio-captions/button/component'; import CaptionsReaderMenuContainer from '/imports/ui/components/captions/reader-menu/container'; import ScreenshareButtonContainer from '/imports/ui/components/actions-bar/screenshare/container'; import ReactionsButtonContainer from './reactions-button/container'; diff --git a/bigbluebutton-html5/imports/ui/components/app/container.jsx b/bigbluebutton-html5/imports/ui/components/app/container.jsx index 5fcd0183aa..7e44f892e9 100755 --- a/bigbluebutton-html5/imports/ui/components/app/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/app/container.jsx @@ -3,7 +3,7 @@ import { withTracker } from 'meteor/react-meteor-data'; import Auth from '/imports/ui/services/auth'; import Users from '/imports/api/users'; import Meetings, { LayoutMeetings } from '/imports/api/meetings'; -import AudioCaptionsLiveContainer from '/imports/ui/components/audio/captions/live/container'; +import AudioCaptionsLiveContainer from '/imports/ui/components/audio/audio-graphql/audio-captions/live/component'; import AudioCaptionsService from '/imports/ui/components/audio/captions/service'; import { notify } from '/imports/ui/services/notification'; import CaptionsContainer from '/imports/ui/components/captions/live/container'; diff --git a/bigbluebutton-html5/imports/ui/components/audio/captions/button/component.jsx b/bigbluebutton-html5/imports/ui/components/audio/captions/button/component.jsx deleted file mode 100644 index b79ffe4ad2..0000000000 --- a/bigbluebutton-html5/imports/ui/components/audio/captions/button/component.jsx +++ /dev/null @@ -1,259 +0,0 @@ -import React, { useRef, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { defineMessages, injectIntl } from 'react-intl'; -import Service from '/imports/ui/components/audio/captions/service'; -import SpeechService from '/imports/ui/components/audio/captions/speech/service'; -import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji'; -import BBBMenu from '/imports/ui/components/common/menu/component'; -import Styled from './styles'; -import { useMutation } from '@apollo/client'; -import { SET_SPEECH_LOCALE } from '/imports/ui/core/graphql/mutations/userMutations'; - -const intlMessages = defineMessages({ - start: { - id: 'app.audio.captions.button.start', - description: 'Start audio captions', - }, - stop: { - id: 'app.audio.captions.button.stop', - description: 'Stop audio captions', - }, - transcriptionSettings: { - id: 'app.audio.captions.button.transcriptionSettings', - description: 'Audio captions settings modal', - }, - transcription: { - id: 'app.audio.captions.button.transcription', - description: 'Audio speech transcription label', - }, - transcriptionOn: { - id: 'app.switch.onLabel', - }, - transcriptionOff: { - id: 'app.switch.offLabel', - }, - language: { - id: 'app.audio.captions.button.language', - description: 'Audio speech recognition language label', - }, - 'de-DE': { - id: 'app.audio.captions.select.de-DE', - description: 'Audio speech recognition german language', - }, - 'en-US': { - id: 'app.audio.captions.select.en-US', - description: 'Audio speech recognition english language', - }, - 'es-ES': { - id: 'app.audio.captions.select.es-ES', - description: 'Audio speech recognition spanish language', - }, - 'fr-FR': { - id: 'app.audio.captions.select.fr-FR', - description: 'Audio speech recognition french language', - }, - 'hi-ID': { - id: 'app.audio.captions.select.hi-ID', - description: 'Audio speech recognition indian language', - }, - 'it-IT': { - id: 'app.audio.captions.select.it-IT', - description: 'Audio speech recognition italian language', - }, - 'ja-JP': { - id: 'app.audio.captions.select.ja-JP', - description: 'Audio speech recognition japanese language', - }, - 'pt-BR': { - id: 'app.audio.captions.select.pt-BR', - description: 'Audio speech recognition portuguese language', - }, - 'ru-RU': { - id: 'app.audio.captions.select.ru-RU', - description: 'Audio speech recognition russian language', - }, - 'zh-CN': { - id: 'app.audio.captions.select.zh-CN', - description: 'Audio speech recognition chinese language', - }, -}); - -const DEFAULT_LOCALE = 'en-US'; -const DISABLED = ''; - -const CaptionsButton = ({ - intl, - active, - isRTL, - enabled, - currentSpeechLocale, - availableVoices, - isSupported, - isVoiceUser, -}) => { - const isTranscriptionDisabled = () => ( - currentSpeechLocale === DISABLED - ); - - const [setSpeechLocale] = useMutation(SET_SPEECH_LOCALE); - - const setUserSpeechLocale = (speechLocale, provider) => { - setSpeechLocale({ - variables: { - locale: speechLocale, - provider, - }, - }); - }; - - const fallbackLocale = availableVoices.includes(navigator.language) - ? navigator.language : DEFAULT_LOCALE; - - const getSelectedLocaleValue = (isTranscriptionDisabled() ? fallbackLocale : currentSpeechLocale); - - const selectedLocale = useRef(getSelectedLocaleValue); - - useEffect(() => { - if (!isTranscriptionDisabled()) selectedLocale.current = getSelectedLocaleValue; - }, [currentSpeechLocale]); - - if (!enabled) return null; - - const shouldRenderChevron = isSupported && isVoiceUser; - - const getAvailableLocales = () => { - let indexToInsertSeparator = -1; - const availableVoicesObjectToMenu = availableVoices.map((availableVoice, index) => { - if (availableVoice === availableVoices[0]) { - indexToInsertSeparator = index; - } - return ( - { - icon: '', - label: intl.formatMessage(intlMessages[availableVoice]), - key: availableVoice, - iconRight: selectedLocale.current === availableVoice ? 'check' : null, - customStyles: (selectedLocale.current === availableVoice) && Styled.SelectedLabel, - disabled: isTranscriptionDisabled(), - onClick: () => { - selectedLocale.current = availableVoice; - SpeechService.setSpeechLocale(selectedLocale.current, setUserSpeechLocale); - }, - } - ); - }); - if (indexToInsertSeparator >= 0) { - availableVoicesObjectToMenu.splice(indexToInsertSeparator, 0, { - key: 'separator-01', - isSeparator: true, - }); - } - return [ - ...availableVoicesObjectToMenu, - ]; - }; - - const toggleTranscription = () => { - SpeechService.setSpeechLocale( - isTranscriptionDisabled() ? selectedLocale.current : DISABLED, setUserSpeechLocale, - ); - }; - - const getAvailableLocalesList = () => ( - [{ - key: 'availableLocalesList', - label: intl.formatMessage(intlMessages.language), - customStyles: Styled.TitleLabel, - disabled: true, - }, - ...getAvailableLocales(), - { - key: 'divider', - label: intl.formatMessage(intlMessages.transcription), - customStyles: Styled.TitleLabel, - disabled: true, - }, - { - key: 'separator-02', - isSeparator: true, - }, - { - key: 'transcriptionStatus', - label: intl.formatMessage( - isTranscriptionDisabled() - ? intlMessages.transcriptionOn - : intlMessages.transcriptionOff, - ), - customStyles: isTranscriptionDisabled() - ? Styled.EnableTrascription : Styled.DisableTrascription, - disabled: false, - onClick: toggleTranscription, - }] - ); - - const onToggleClick = (e) => { - e.stopPropagation(); - Service.setAudioCaptions(!active); - }; - - const startStopCaptionsButton = ( - - ); - - return ( - shouldRenderChevron - ? ( - - - { startStopCaptionsButton } - - - )} - actions={getAvailableLocalesList()} - opts={{ - id: 'default-dropdown-menu', - keepMounted: true, - transitionDuration: 0, - elevation: 3, - getcontentanchorel: null, - fullwidth: 'true', - anchorOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' }, - transformOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' }, - }} - /> - - ) : startStopCaptionsButton - ); -}; - -CaptionsButton.propTypes = { - intl: PropTypes.shape({ - formatMessage: PropTypes.func.isRequired, - }).isRequired, - active: PropTypes.bool.isRequired, - isRTL: PropTypes.bool.isRequired, - enabled: PropTypes.bool.isRequired, - currentSpeechLocale: PropTypes.string.isRequired, - availableVoices: PropTypes.arrayOf(PropTypes.string).isRequired, - isSupported: PropTypes.bool.isRequired, - isVoiceUser: PropTypes.bool.isRequired, -}; - -export default injectIntl(CaptionsButton); diff --git a/bigbluebutton-html5/imports/ui/components/audio/captions/button/container.jsx b/bigbluebutton-html5/imports/ui/components/audio/captions/button/container.jsx deleted file mode 100644 index 11fc878ad3..0000000000 --- a/bigbluebutton-html5/imports/ui/components/audio/captions/button/container.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { withTracker } from 'meteor/react-meteor-data'; -import Service from '/imports/ui/components/audio/captions/service'; -import Button from './component'; -import SpeechService from '/imports/ui/components/audio/captions/speech/service'; -import AudioService from '/imports/ui/components/audio/service'; -import AudioCaptionsButtonContainer from '../../audio-graphql/audio-captions/button/component'; - -const Container = (props) =>

); diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js b/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js index 497cc7628a..c7d6e63173 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js @@ -33,6 +33,16 @@ const TldrawV2GlobalStyle = createGlobalStyle` } `} + ${({ isToolbarVisible }) => (!isToolbarVisible) && ` + .tlui-toolbar { + visibility: hidden; + } + + .tlui-style-panel__wrapper { + visibility: hidden; + } + `} + #presentationInnerWrapper > div:last-child { position: relative; height: 100%; From 06e55d4f8e12ff504ffe0dc3f4d26a750f56ed68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Fri, 2 Feb 2024 09:58:58 -0300 Subject: [PATCH 0305/1039] fix(whiteboard): hide menu bar on hide toolbars --- .../presentation/presentation-menu/component.jsx | 2 +- .../imports/ui/components/whiteboard/styles.js | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx index 449bad91b8..1359a89f94 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx @@ -358,7 +358,7 @@ const PresentationMenu = (props) => { ); } - const tools = document.querySelector('.tlui-toolbar, .tlui-style-panel__wrapper'); + const tools = document.querySelector('.tlui-toolbar, .tlui-style-panel__wrapper, .tlui-menu-zone'); if (tools && (props.hasWBAccess || props.amIPresenter)){ menuItems.push( { diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js b/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js index c7d6e63173..bc0eb1fe21 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js @@ -34,11 +34,9 @@ const TldrawV2GlobalStyle = createGlobalStyle` `} ${({ isToolbarVisible }) => (!isToolbarVisible) && ` - .tlui-toolbar { - visibility: hidden; - } - - .tlui-style-panel__wrapper { + .tlui-toolbar, + .tlui-style-panel__wrapper, + .tlui-menu-zone { visibility: hidden; } `} From b8ce51a372d1d30d662151115864a6454712de04 Mon Sep 17 00:00:00 2001 From: Guilherme Leme Date: Fri, 2 Feb 2024 10:49:55 -0300 Subject: [PATCH 0306/1039] [i-19517] - WIP flow to send presentation's name to chat download --- .../MakePresentationDownloadReqMsgHdlr.scala | 7 +++---- .../StoreExportJobInRedisPresAnnEvent.scala | 6 +++--- .../endpoint/redis/ExportAnnotationsActor.scala | 2 +- .../common2/msgs/PresentationMsgs.scala | 2 +- .../common2/msgs/WhiteboardMsgs.scala | 2 +- .../lib/utils/message-builder.js | 2 +- bbb-export-annotations/workers/collector.js | 6 +++--- bbb-export-annotations/workers/notifier.js | 15 +++++---------- bbb-export-annotations/workers/process.js | 5 +++-- 9 files changed, 21 insertions(+), 26 deletions(-) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/MakePresentationDownloadReqMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/MakePresentationDownloadReqMsgHdlr.scala index 19c23f31bc..f4ceb03139 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/MakePresentationDownloadReqMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/MakePresentationDownloadReqMsgHdlr.scala @@ -60,8 +60,7 @@ trait MakePresentationDownloadReqMsgHdlr extends RightsManagementTrait { originalFileURI = newPresFileAvailableMsg.body.originalFileURI, convertedFileURI = newPresFileAvailableMsg.body.convertedFileURI, presId = newPresFileAvailableMsg.body.presId, - fileStateType = newPresFileAvailableMsg.body.fileStateType, - fileName = newPresFileAvailableMsg.body.fileName + fileStateType = newPresFileAvailableMsg.body.fileStateType ) val event = NewPresFileAvailableEvtMsg(header, body) BbbCommonEnvCoreMsg(envelope, event) @@ -160,8 +159,8 @@ trait MakePresentationDownloadReqMsgHdlr extends RightsManagementTrait { val presLocation = List("var", "bigbluebutton", meetingId, meetingId, presId).mkString(File.separator, File.separator, ""); val pages: List[Int] = m.body.pages // Desired presentation pages for export val pagesRange: List[Int] = if (allPages) (1 to pageCount).toList else pages - // aaa - val exportJob: ExportJob = new ExportJob(jobId, JobTypes.DOWNLOAD, "annotated_slides", currentPres.get.name, presId, presLocation, allPages, pagesRange, meetingId, ""); + + val exportJob: ExportJob = new ExportJob(jobId, JobTypes.DOWNLOAD, currentPres.get.name, "annotated_slides", presId, presLocation, allPages, pagesRange, meetingId, ""); val storeAnnotationPages: List[PresentationPageForExport] = getPresentationPagesForExport(pagesRange, pageCount, presId, currentPres, liveMeeting); val isPresentationOriginalOrConverted = m.body.fileStateType == "Original" || m.body.fileStateType == "Converted" diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/StoreExportJobInRedisPresAnnEvent.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/StoreExportJobInRedisPresAnnEvent.scala index 66aa5322e6..a9be647d09 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/StoreExportJobInRedisPresAnnEvent.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/StoreExportJobInRedisPresAnnEvent.scala @@ -27,8 +27,8 @@ class StoreExportJobInRedisPresAnnEvent extends AbstractPresentationWithAnnotati setEvent("StoreExportJobInRedisPresAnnEvent") - def setOriginalFileName(originalFileName: String) { - eventMap.put(ORIGINAL_FILENAME, originalFileName) + def setNameToSave(nameToSave: String) { + eventMap.put(NAME_TO_SAVE, nameToSave) } def setJobId(jobId: String) { @@ -72,9 +72,9 @@ object StoreExportJobInRedisPresAnnEvent { protected final val JOB_ID = "jobId" protected final val JOB_TYPE = "jobType" protected final val FILENAME = "filename" + protected final val NAME_TO_SAVE = "nameToSave" protected final val PRES_ID = "presId" protected final val PRES_LOCATION = "presLocation" - protected final val ORIGINAL_FILENAME = "originalFilename" protected final val ALL_PAGES = "allPages" protected final val PAGES = "pages" protected final val PARENT_MEETING_ID = "parentMeetingId" diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/ExportAnnotationsActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/ExportAnnotationsActor.scala index 91342ed387..0c904fc97d 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/ExportAnnotationsActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/ExportAnnotationsActor.scala @@ -73,7 +73,7 @@ class ExportAnnotationsActor( private def handleStoreExportJobInRedisSysMsg(msg: StoreExportJobInRedisSysMsg) { val ev = new StoreExportJobInRedisPresAnnEvent() - ev.setOriginalFileName(msg.body.exportJob.originalFilename) + ev.setNameToSave(msg.body.exportJob.nameToSave) ev.setJobId(msg.body.exportJob.jobId) ev.setJobType(msg.body.exportJob.jobType) ev.setFilename(msg.body.exportJob.filename) diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/PresentationMsgs.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/PresentationMsgs.scala index 72cd92df70..9e3d9c5ea1 100755 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/PresentationMsgs.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/PresentationMsgs.scala @@ -41,7 +41,7 @@ case class NewPresentationEvtMsgBody(presentation: PresentationVO) object NewPresFileAvailableEvtMsg { val NAME = "NewPresFileAvailableEvtMsg" } case class NewPresFileAvailableEvtMsg(header: BbbClientMsgHeader, body: NewPresFileAvailableEvtMsgBody) extends BbbCoreMsg case class NewPresFileAvailableEvtMsgBody(annotatedFileURI: String, originalFileURI: String, convertedFileURI: String, - presId: String, fileStateType: String, fileName: String) + presId: String, fileStateType: String) object PresAnnStatusEvtMsg { val NAME = "PresAnnStatusEvtMsg" } case class PresAnnStatusEvtMsg(header: BbbClientMsgHeader, body: PresAnnStatusEvtMsgBody) extends BbbCoreMsg diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala index 349e5fd46e..2bd914386d 100755 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala @@ -22,7 +22,7 @@ case class ExportJob( jobId: String, jobType: String, filename: String, - originalFilename: String, + nameToSave: String, presId: String, presLocation: String, allPages: Boolean, diff --git a/bbb-export-annotations/lib/utils/message-builder.js b/bbb-export-annotations/lib/utils/message-builder.js index 7f3efa473d..a5dccda2eb 100644 --- a/bbb-export-annotations/lib/utils/message-builder.js +++ b/bbb-export-annotations/lib/utils/message-builder.js @@ -73,7 +73,7 @@ class NewPresFileAvailableMsg { annotatedFileURI: link, originalFileURI: '', convertedFileURI: '', - fileName: exportJob.originalFilename, + fileName: exportJob.filename, presId: exportJob.presId, fileStateType: 'Annotated', }, diff --git a/bbb-export-annotations/workers/collector.js b/bbb-export-annotations/workers/collector.js index 8448363da8..11e64f2b8a 100644 --- a/bbb-export-annotations/workers/collector.js +++ b/bbb-export-annotations/workers/collector.js @@ -129,9 +129,9 @@ async function collectSharedNotes(retries = 3) { const padId = exportJob.presId; const notesFormat = 'pdf'; - const filename = `${sanitize(exportJob.filename.replace(/\s/g, '_'))}.${notesFormat}`; + const nameToSave = `${sanitize(exportJob.nameToSave.replace(/\s/g, '_'))}.${notesFormat}`; const notes_endpoint = `${config.bbbPadsAPI}/p/${padId}/export/${notesFormat}`; - const filePath = path.join(dropbox, filename); + const filePath = path.join(dropbox, nameToSave); const finishedDownload = promisify(stream.finished); const writer = fs.createWriteStream(filePath); @@ -157,7 +157,7 @@ async function collectSharedNotes(retries = 3) { } } - const notifier = new WorkerStarter({jobType, jobId, filename}); + const notifier = new WorkerStarter({jobType, jobId, nameToSave, filename: exportJob.filename}); notifier.notify(); } diff --git a/bbb-export-annotations/workers/notifier.js b/bbb-export-annotations/workers/notifier.js index a0cc74984d..37e38043b4 100644 --- a/bbb-export-annotations/workers/notifier.js +++ b/bbb-export-annotations/workers/notifier.js @@ -8,7 +8,7 @@ const path = require('path'); const {NewPresFileAvailableMsg} = require('../lib/utils/message-builder'); const {workerData} = require('worker_threads'); -const [jobType, jobId, filename] = [workerData.jobType, workerData.jobId, workerData.filename]; +const [jobType, jobId, nameToSave] = [workerData.jobType, workerData.jobId, workerData.nameToSave]; const logger = new Logger('presAnn Notifier Worker'); @@ -28,18 +28,13 @@ async function notifyMeetingActor() { await client.connect(); client.on('error', (err) => logger.info('Redis Client Error', err)); - const annotated_slides = 'annotated_slides.pdf'; const link = path.join('presentation', exportJob.parentMeetingId, exportJob.parentMeetingId, - exportJob.presId, 'pdf', jobId, annotated_slides); + exportJob.presId, 'pdf', jobId, nameToSave); - const notification = new NewPresFileAvailableMsg(exportJob, link,); + const notification = new NewPresFileAvailableMsg(exportJob, link); logger.info(`Annotated PDF available at ${link}`); - logger.info(`\n\n\n\n\n --->${JSON.stringify(exportJob)}`); - logger.info(`\n\n\n\n\n --->${JSON.stringify(workerData)}`); - - await client.publish(config.redis.channels.publish, notification.build()); client.disconnect(); @@ -69,10 +64,10 @@ async function upload(filePath) { if (jobType == 'PresentationWithAnnotationDownloadJob') { notifyMeetingActor(); } else if (jobType == 'PresentationWithAnnotationExportJob') { - const filePath = `${exportJob.presLocation}/pdfs/${jobId}/${filename}`; + const filePath = `${exportJob.presLocation}/pdfs/${jobId}/${nameToSave}`; upload(filePath); } else if (jobType == 'PadCaptureJob') { - const filePath = `${dropbox}/${filename}`; + const filePath = `${dropbox}/${nameToSave}`; upload(filePath); } else { logger.error(`Notifier received unknown job type ${jobType}`); diff --git a/bbb-export-annotations/workers/process.js b/bbb-export-annotations/workers/process.js index 9f61d22050..72f9c61eb6 100644 --- a/bbb-export-annotations/workers/process.js +++ b/bbb-export-annotations/workers/process.js @@ -886,7 +886,7 @@ async function process_presentation_annotations() { fs.mkdirSync(outputDir, {recursive: true}); } - const filename_with_extension = `${sanitize(exportJob.filename.replace(/\s/g, '_'))}.pdf`; + const filename_with_extension = `${sanitize(exportJob.nameToSave.replace(/\s/g, '_'))}.pdf`; const mergePDFs = [ '-dNOPAUSE', @@ -904,7 +904,8 @@ async function process_presentation_annotations() { // Launch Notifier Worker depending on job type logger.info(`Saved PDF at ${outputDir}/${jobId}/${filename_with_extension}`); - const notifier = new WorkerStarter({jobType: exportJob.jobType, jobId, filename: filename_with_extension}); + const notifier = new WorkerStarter({jobType: exportJob.jobType, jobId, + nameToSave: filename_with_extension, filename: exportJob.filename}); notifier.notify(); await client.disconnect(); } From 8ac28cfe810014b112ae5c122d5369f09bbf79ed Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Fri, 2 Feb 2024 10:52:50 -0300 Subject: [PATCH 0307/1039] Always give permisson for Frontend user in db --- bbb-graphql-server/install-hasura.sh | 3 ++- bbb-graphql-server/update_graphql_data.sh | 2 +- build/packages-template/bbb-graphql-server/after-install.sh | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bbb-graphql-server/install-hasura.sh b/bbb-graphql-server/install-hasura.sh index 26948b679a..01d87cc7b2 100755 --- a/bbb-graphql-server/install-hasura.sh +++ b/bbb-graphql-server/install-hasura.sh @@ -29,10 +29,11 @@ else sudo -u postgres psql -q -c "GRANT CONNECT ON DATABASE bbb_graphql TO $DATABASE_FRONTEND_USER" sudo -u postgres psql -q -d bbb_graphql -c "REVOKE ALL ON ALL TABLES IN SCHEMA public FROM $DATABASE_FRONTEND_USER" sudo -u postgres psql -q -d bbb_graphql -c "GRANT USAGE ON SCHEMA public TO $DATABASE_FRONTEND_USER" - sudo -u postgres psql -q -d bbb_graphql -c "GRANT SELECT ON v_user_connection_auth TO $DATABASE_FRONTEND_USER" echo "User $DATABASE_FRONTEND_USER created on database bbb_graphql" fi +sudo -u postgres psql -q -d bbb_graphql -c "GRANT SELECT ON v_user_connection_auth TO $DATABASE_FRONTEND_USER" + echo "Postgresql installed!" diff --git a/bbb-graphql-server/update_graphql_data.sh b/bbb-graphql-server/update_graphql_data.sh index 44ce3e544e..e8868637c9 100755 --- a/bbb-graphql-server/update_graphql_data.sh +++ b/bbb-graphql-server/update_graphql_data.sh @@ -35,10 +35,10 @@ else sudo -u postgres psql -q -c "GRANT CONNECT ON DATABASE bbb_graphql TO $DATABASE_FRONTEND_USER" sudo -u postgres psql -q -d bbb_graphql -c "REVOKE ALL ON ALL TABLES IN SCHEMA public FROM $DATABASE_FRONTEND_USER" sudo -u postgres psql -q -d bbb_graphql -c "GRANT USAGE ON SCHEMA public TO $DATABASE_FRONTEND_USER" - sudo -u postgres psql -q -d bbb_graphql -c "GRANT SELECT ON v_user_connection_auth TO $DATABASE_FRONTEND_USER" echo "User $DATABASE_FRONTEND_USER created on database bbb_graphql" fi +sudo -u postgres psql -q -d bbb_graphql -c "GRANT SELECT ON v_user_connection_auth TO $DATABASE_FRONTEND_USER" if [ "$hasura_status" = "active" ]; then echo "Starting Hasura" diff --git a/build/packages-template/bbb-graphql-server/after-install.sh b/build/packages-template/bbb-graphql-server/after-install.sh index f24cb5395c..d0107c9cbc 100755 --- a/build/packages-template/bbb-graphql-server/after-install.sh +++ b/build/packages-template/bbb-graphql-server/after-install.sh @@ -32,10 +32,10 @@ case "$1" in sudo -u postgres psql -q -c "GRANT CONNECT ON DATABASE bbb_graphql TO $DATABASE_FRONTEND_USER" sudo -u postgres psql -q -d bbb_graphql -c "REVOKE ALL ON ALL TABLES IN SCHEMA public FROM $DATABASE_FRONTEND_USER" sudo -u postgres psql -q -d bbb_graphql -c "GRANT USAGE ON SCHEMA public TO $DATABASE_FRONTEND_USER" - sudo -u postgres psql -q -d bbb_graphql -c "GRANT SELECT ON v_user_connection_auth TO $DATABASE_FRONTEND_USER" echo "User $DATABASE_FRONTEND_USER created on database bbb_graphql" fi + sudo -u postgres psql -q -d bbb_graphql -c "GRANT SELECT ON v_user_connection_auth TO $DATABASE_FRONTEND_USER" echo "Postgresql configured" From 7c9d1c63524fa69c4a086677d44db82d2c509193 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Fri, 2 Feb 2024 11:00:18 -0300 Subject: [PATCH 0308/1039] Fix multi-user whiteboard not working --- bbb-graphql-actions/src/actions/presAnnotationDelete.ts | 3 --- bbb-graphql-actions/src/actions/presAnnotationDeleteAll.ts | 3 --- bbb-graphql-actions/src/actions/presAnnotationSubmit.ts | 2 -- 3 files changed, 8 deletions(-) diff --git a/bbb-graphql-actions/src/actions/presAnnotationDelete.ts b/bbb-graphql-actions/src/actions/presAnnotationDelete.ts index 68fce2006b..69a1f13ba9 100644 --- a/bbb-graphql-actions/src/actions/presAnnotationDelete.ts +++ b/bbb-graphql-actions/src/actions/presAnnotationDelete.ts @@ -1,9 +1,6 @@ import { RedisMessage } from '../types'; -import { ValidationError } from '../types/ValidationError'; -import {throwErrorIfNotPresenter} from "../imports/validation"; export default function buildRedisMessage(sessionVariables: Record, input: Record): RedisMessage { - throwErrorIfNotPresenter(sessionVariables); const eventName = `DeleteWhiteboardAnnotationsPubMsg`; const routing = { diff --git a/bbb-graphql-actions/src/actions/presAnnotationDeleteAll.ts b/bbb-graphql-actions/src/actions/presAnnotationDeleteAll.ts index a969d6efc7..6d26a6b160 100644 --- a/bbb-graphql-actions/src/actions/presAnnotationDeleteAll.ts +++ b/bbb-graphql-actions/src/actions/presAnnotationDeleteAll.ts @@ -1,9 +1,6 @@ import { RedisMessage } from '../types'; -import { ValidationError } from '../types/ValidationError'; -import {throwErrorIfNotPresenter} from "../imports/validation"; export default function buildRedisMessage(sessionVariables: Record, input: Record): RedisMessage { - throwErrorIfNotPresenter(sessionVariables); const eventName = `DeleteWhiteboardAnnotationsPubMsg`; const routing = { diff --git a/bbb-graphql-actions/src/actions/presAnnotationSubmit.ts b/bbb-graphql-actions/src/actions/presAnnotationSubmit.ts index a9129c93ae..e79efe26c7 100644 --- a/bbb-graphql-actions/src/actions/presAnnotationSubmit.ts +++ b/bbb-graphql-actions/src/actions/presAnnotationSubmit.ts @@ -1,9 +1,7 @@ import { RedisMessage } from '../types'; import { ValidationError } from '../types/ValidationError'; -import {throwErrorIfNotPresenter} from "../imports/validation"; export default function buildRedisMessage(sessionVariables: Record, input: Record): RedisMessage { - throwErrorIfNotPresenter(sessionVariables); const eventName = `SendWhiteboardAnnotationsPubMsg`; const routing = { From b1c90f3733619759e71806df67667ea95a908d57 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Fri, 2 Feb 2024 11:14:47 -0300 Subject: [PATCH 0309/1039] Bump Hasura from 2.36.0 to 2.37.0 --- bbb-graphql-server/install-hasura.sh | 2 +- build/packages-template/bbb-graphql-server/build.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bbb-graphql-server/install-hasura.sh b/bbb-graphql-server/install-hasura.sh index 26948b679a..a94a6f5f44 100755 --- a/bbb-graphql-server/install-hasura.sh +++ b/bbb-graphql-server/install-hasura.sh @@ -54,7 +54,7 @@ systemctl restart nginx #chmod +x /usr/local/bin/hasura-graphql-engine #Hasura 2.29+ requires Ubuntu 22 -git clone --branch v2.36.0 https://github.com/iMDT/hasura-graphql-engine.git +git clone --branch v2.37.0 https://github.com/iMDT/hasura-graphql-engine.git cat hasura-graphql-engine/hasura-graphql.part-a* > hasura-graphql rm -rf hasura-graphql-engine/ chmod +x hasura-graphql diff --git a/build/packages-template/bbb-graphql-server/build.sh b/build/packages-template/bbb-graphql-server/build.sh index 2ecc4c6ac0..ac7a6c1b95 100755 --- a/build/packages-template/bbb-graphql-server/build.sh +++ b/build/packages-template/bbb-graphql-server/build.sh @@ -22,7 +22,7 @@ for dir in $DIRS; do mkdir -p staging$dir done -git clone --branch v2.36.0 https://github.com/iMDT/hasura-graphql-engine.git +git clone --branch v2.37.0 https://github.com/iMDT/hasura-graphql-engine.git cat hasura-graphql-engine/hasura-graphql.part-a* > hasura-graphql rm -rf hasura-graphql-engine/ chmod +x hasura-graphql From bb07edd34e260f6eefb7b6f95aaf19638974c48e Mon Sep 17 00:00:00 2001 From: Guilherme Leme Date: Fri, 2 Feb 2024 11:25:13 -0300 Subject: [PATCH 0310/1039] [i-19517] - refactor --- .../record/events/StoreExportJobInRedisPresAnnEvent.scala | 6 +++--- .../endpoint/redis/ExportAnnotationsActor.scala | 2 +- .../org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala | 2 +- bbb-export-annotations/workers/collector.js | 6 +++--- bbb-export-annotations/workers/notifier.js | 8 ++++---- bbb-export-annotations/workers/process.js | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/StoreExportJobInRedisPresAnnEvent.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/StoreExportJobInRedisPresAnnEvent.scala index a9be647d09..6a8381eaca 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/StoreExportJobInRedisPresAnnEvent.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/StoreExportJobInRedisPresAnnEvent.scala @@ -27,8 +27,8 @@ class StoreExportJobInRedisPresAnnEvent extends AbstractPresentationWithAnnotati setEvent("StoreExportJobInRedisPresAnnEvent") - def setNameToSave(nameToSave: String) { - eventMap.put(NAME_TO_SAVE, nameToSave) + def setFileNameToPath(fileNameToPath: String) { + eventMap.put(FILE_NAME_TO_PATH, fileNameToPath) } def setJobId(jobId: String) { @@ -72,7 +72,7 @@ object StoreExportJobInRedisPresAnnEvent { protected final val JOB_ID = "jobId" protected final val JOB_TYPE = "jobType" protected final val FILENAME = "filename" - protected final val NAME_TO_SAVE = "nameToSave" + protected final val FILE_NAME_TO_PATH = "fileNameToPath" protected final val PRES_ID = "presId" protected final val PRES_LOCATION = "presLocation" protected final val ALL_PAGES = "allPages" diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/ExportAnnotationsActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/ExportAnnotationsActor.scala index 0c904fc97d..4b838ee66e 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/ExportAnnotationsActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/ExportAnnotationsActor.scala @@ -73,7 +73,7 @@ class ExportAnnotationsActor( private def handleStoreExportJobInRedisSysMsg(msg: StoreExportJobInRedisSysMsg) { val ev = new StoreExportJobInRedisPresAnnEvent() - ev.setNameToSave(msg.body.exportJob.nameToSave) + ev.setFileNameToPath(msg.body.exportJob.fileNameToPath) ev.setJobId(msg.body.exportJob.jobId) ev.setJobType(msg.body.exportJob.jobType) ev.setFilename(msg.body.exportJob.filename) diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala index 2bd914386d..3545cb30db 100755 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala @@ -22,7 +22,7 @@ case class ExportJob( jobId: String, jobType: String, filename: String, - nameToSave: String, + fileNameToPath: String, presId: String, presLocation: String, allPages: Boolean, diff --git a/bbb-export-annotations/workers/collector.js b/bbb-export-annotations/workers/collector.js index 11e64f2b8a..bf5ce07c6f 100644 --- a/bbb-export-annotations/workers/collector.js +++ b/bbb-export-annotations/workers/collector.js @@ -129,9 +129,9 @@ async function collectSharedNotes(retries = 3) { const padId = exportJob.presId; const notesFormat = 'pdf'; - const nameToSave = `${sanitize(exportJob.nameToSave.replace(/\s/g, '_'))}.${notesFormat}`; + const fileNameToPath = `${sanitize(exportJob.fileNameToPath.replace(/\s/g, '_'))}.${notesFormat}`; const notes_endpoint = `${config.bbbPadsAPI}/p/${padId}/export/${notesFormat}`; - const filePath = path.join(dropbox, nameToSave); + const filePath = path.join(dropbox, fileNameToPath); const finishedDownload = promisify(stream.finished); const writer = fs.createWriteStream(filePath); @@ -157,7 +157,7 @@ async function collectSharedNotes(retries = 3) { } } - const notifier = new WorkerStarter({jobType, jobId, nameToSave, filename: exportJob.filename}); + const notifier = new WorkerStarter({jobType, jobId, fileNameToPath, filename: exportJob.filename}); notifier.notify(); } diff --git a/bbb-export-annotations/workers/notifier.js b/bbb-export-annotations/workers/notifier.js index 37e38043b4..c1084c6c1f 100644 --- a/bbb-export-annotations/workers/notifier.js +++ b/bbb-export-annotations/workers/notifier.js @@ -8,7 +8,7 @@ const path = require('path'); const {NewPresFileAvailableMsg} = require('../lib/utils/message-builder'); const {workerData} = require('worker_threads'); -const [jobType, jobId, nameToSave] = [workerData.jobType, workerData.jobId, workerData.nameToSave]; +const [jobType, jobId, fileNameToPath] = [workerData.jobType, workerData.jobId, workerData.fileNameToPath]; const logger = new Logger('presAnn Notifier Worker'); @@ -30,7 +30,7 @@ async function notifyMeetingActor() { const link = path.join('presentation', exportJob.parentMeetingId, exportJob.parentMeetingId, - exportJob.presId, 'pdf', jobId, nameToSave); + exportJob.presId, 'pdf', jobId, fileNameToPath); const notification = new NewPresFileAvailableMsg(exportJob, link); @@ -64,10 +64,10 @@ async function upload(filePath) { if (jobType == 'PresentationWithAnnotationDownloadJob') { notifyMeetingActor(); } else if (jobType == 'PresentationWithAnnotationExportJob') { - const filePath = `${exportJob.presLocation}/pdfs/${jobId}/${nameToSave}`; + const filePath = `${exportJob.presLocation}/pdfs/${jobId}/${fileNameToPath}`; upload(filePath); } else if (jobType == 'PadCaptureJob') { - const filePath = `${dropbox}/${nameToSave}`; + const filePath = `${dropbox}/${fileNameToPath}`; upload(filePath); } else { logger.error(`Notifier received unknown job type ${jobType}`); diff --git a/bbb-export-annotations/workers/process.js b/bbb-export-annotations/workers/process.js index 72f9c61eb6..e0efd44bf2 100644 --- a/bbb-export-annotations/workers/process.js +++ b/bbb-export-annotations/workers/process.js @@ -886,7 +886,7 @@ async function process_presentation_annotations() { fs.mkdirSync(outputDir, {recursive: true}); } - const filename_with_extension = `${sanitize(exportJob.nameToSave.replace(/\s/g, '_'))}.pdf`; + const filename_with_extension = `${sanitize(exportJob.fileNameToPath.replace(/\s/g, '_'))}.pdf`; const mergePDFs = [ '-dNOPAUSE', @@ -905,7 +905,7 @@ async function process_presentation_annotations() { logger.info(`Saved PDF at ${outputDir}/${jobId}/${filename_with_extension}`); const notifier = new WorkerStarter({jobType: exportJob.jobType, jobId, - nameToSave: filename_with_extension, filename: exportJob.filename}); + fileNameToPath: filename_with_extension, filename: exportJob.filename}); notifier.notify(); await client.disconnect(); } From 5708c2506b7052049b35f22d486ee83475ea07be Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Fri, 2 Feb 2024 12:36:27 -0300 Subject: [PATCH 0311/1039] enhancement (graphql-middleware): Data Uniqueness Verification and others (#19559) * Prevent middleware from sending the same message when reconnection * Refactor hasura reader to simplify code * Its not necessary to name the for * Close hasura connnection on error --- .../internal/common/Hasher.go | 12 ++ .../internal/common/types.go | 1 + .../internal/hascli/conn/reader/reader.go | 156 ++++++++++++------ .../internal/hascli/conn/writer/writer.go | 12 +- .../internal/msgpatch/jsonpatch.go | 121 +++++--------- 5 files changed, 168 insertions(+), 134 deletions(-) create mode 100644 bbb-graphql-middleware/internal/common/Hasher.go diff --git a/bbb-graphql-middleware/internal/common/Hasher.go b/bbb-graphql-middleware/internal/common/Hasher.go new file mode 100644 index 0000000000..8bab1665c4 --- /dev/null +++ b/bbb-graphql-middleware/internal/common/Hasher.go @@ -0,0 +1,12 @@ +package common + +import ( + "crypto/sha256" + "fmt" +) + +func GenerateSha256(data []byte) string { + hasher := sha256.New() + hasher.Write(data) + return fmt.Sprintf("%x", hasher.Sum(nil)) +} diff --git a/bbb-graphql-middleware/internal/common/types.go b/bbb-graphql-middleware/internal/common/types.go index c1ccd013b8..1339ff2665 100644 --- a/bbb-graphql-middleware/internal/common/types.go +++ b/bbb-graphql-middleware/internal/common/types.go @@ -25,6 +25,7 @@ type GraphQlSubscription struct { StreamCursorField string StreamCursorVariableName string StreamCursorCurrValue interface{} + LastReceivedDataSha256 string JsonPatchSupported bool // indicate if client support Json Patch for this subscription LastSeenOnHasuraConnection string // id of the hasura connection that this query was active } diff --git a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go index 97fca5929a..4c1bb0afa2 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go +++ b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go @@ -2,6 +2,7 @@ package reader import ( "context" + "encoding/json" "errors" "github.com/iMDT/bbb-graphql-middleware/internal/common" "github.com/iMDT/bbb-graphql-middleware/internal/hascli/retransmiter" @@ -35,69 +36,118 @@ func HasuraConnectionReader(hc *common.HasuraConnection, fromHasuraToBrowserChan log.Tracef("received from hasura: %v", message) - var messageAsMap = message.(map[string]interface{}) + go handleMessageReceivedFromHasura(hc, fromHasuraToBrowserChannel, fromBrowserToHasuraChannel, message) + } +} - if messageAsMap != nil { - var messageType = messageAsMap["type"] - var queryId, _ = messageAsMap["id"].(string) +func handleMessageReceivedFromHasura(hc *common.HasuraConnection, fromHasuraToBrowserChannel *common.SafeChannel, fromBrowserToHasuraChannel *common.SafeChannel, message interface{}) { + var messageMap = message.(map[string]interface{}) - //Check if subscription is still active! - if queryId != "" { - hc.Browserconn.ActiveSubscriptionsMutex.RLock() - subscription, ok := hc.Browserconn.ActiveSubscriptions[queryId] - hc.Browserconn.ActiveSubscriptionsMutex.RUnlock() - if !ok { - log.Debugf("Subscription with Id %s doesn't exist anymore, skiping response.", queryId) - continue - } + if messageMap != nil { + var messageType = messageMap["type"] + var queryId, _ = messageMap["id"].(string) - //When Hasura send msg type "complete", this query is finished - if messageType == "complete" { - hc.Browserconn.ActiveSubscriptionsMutex.Lock() - delete(hc.Browserconn.ActiveSubscriptions, queryId) - hc.Browserconn.ActiveSubscriptionsMutex.Unlock() - log.Debugf("Subscription with Id %s finished by Hasura.", queryId) - } + //Check if subscription is still active! + if queryId != "" { + hc.Browserconn.ActiveSubscriptionsMutex.RLock() + subscription, ok := hc.Browserconn.ActiveSubscriptions[queryId] + hc.Browserconn.ActiveSubscriptionsMutex.RUnlock() + if !ok { + log.Debugf("Subscription with Id %s doesn't exist anymore, skiping response.", queryId) + return + } - //Apply msg patch when it supports it - if subscription.JsonPatchSupported && - messageType == "data" && - subscription.Type == common.Subscription { - msgpatch.PatchMessage(&messageAsMap, hc.Browserconn) - } + //When Hasura send msg type "complete", this query is finished + if messageType == "complete" { + handleCompleteMessage(hc, queryId) + } - //Set last cursor value for stream - if subscription.Type == common.Streaming { - lastCursor := common.GetLastStreamCursorValueFromReceivedMessage(messageAsMap, subscription.StreamCursorField) - if lastCursor != nil && subscription.StreamCursorCurrValue != lastCursor { - subscription.StreamCursorCurrValue = lastCursor - - hc.Browserconn.ActiveSubscriptionsMutex.Lock() - hc.Browserconn.ActiveSubscriptions[queryId] = subscription - hc.Browserconn.ActiveSubscriptionsMutex.Unlock() - } + if messageType == "data" && + subscription.Type == common.Subscription { + hasNoPreviousOccurrence := handleSubscriptionMessage(hc, messageMap, subscription, queryId) + if !hasNoPreviousOccurrence { + return } } - // Retransmit the subscription start commands when hasura confirms the connection - // this is useful in case of a connection invalidation - if messageType == "connection_ack" { - log.Debugf("Received connection_ack") - //Hasura connection was initialized, now it's able to send new messages to Hasura - fromBrowserToHasuraChannel.UnfreezeChannel() - - //Avoid to send `connection_ack` to the browser when it's a reconnection - if hc.Browserconn.ConnAckSentToBrowser == false { - fromHasuraToBrowserChannel.Send(messageAsMap) - hc.Browserconn.ConnAckSentToBrowser = true - } - - go retransmiter.RetransmitSubscriptionStartMessages(hc, fromBrowserToHasuraChannel) - } else { - // Forward the message to browser - fromHasuraToBrowserChannel.Send(messageAsMap) + //Set last cursor value for stream + if subscription.Type == common.Streaming { + handleStreamingMessage(hc, messageMap, subscription, queryId) } } + + // Retransmit the subscription start commands when hasura confirms the connection + // this is useful in case of a connection invalidation + if messageType == "connection_ack" { + handleConnectionAckMessage(hc, messageMap, fromHasuraToBrowserChannel, fromBrowserToHasuraChannel) + } else { + // Forward the message to browser + fromHasuraToBrowserChannel.Send(messageMap) + } } } + +func handleSubscriptionMessage(hc *common.HasuraConnection, messageMap map[string]interface{}, subscription common.GraphQlSubscription, queryId string) bool { + if payload, okPayload := messageMap["payload"].(map[string]interface{}); okPayload { + if data, okData := payload["data"].(map[string]interface{}); okData { + for dataKey, dataItem := range data { + if currentDataProp, okCurrentDataProp := dataItem.([]interface{}); okCurrentDataProp { + if dataAsJson, err := json.Marshal(currentDataProp); err == nil { + //Check whether ReceivedData is different from the LastReceivedData + //Otherwise stop forwarding this message + dataSha256 := common.GenerateSha256(dataAsJson) + if subscription.LastReceivedDataSha256 == dataSha256 { + return false + } + + //Store LastReceivedData Sha256 + subscription.LastReceivedDataSha256 = dataSha256 + hc.Browserconn.ActiveSubscriptionsMutex.Lock() + hc.Browserconn.ActiveSubscriptions[queryId] = subscription + hc.Browserconn.ActiveSubscriptionsMutex.Unlock() + + //Apply msg patch when it supports it + if subscription.JsonPatchSupported { + msgpatch.PatchMessage(&messageMap, queryId, dataKey, dataAsJson, hc.Browserconn) + } + } + } + } + } + } + + return true +} + +func handleStreamingMessage(hc *common.HasuraConnection, messageMap map[string]interface{}, subscription common.GraphQlSubscription, queryId string) { + lastCursor := common.GetLastStreamCursorValueFromReceivedMessage(messageMap, subscription.StreamCursorField) + if lastCursor != nil && subscription.StreamCursorCurrValue != lastCursor { + subscription.StreamCursorCurrValue = lastCursor + + hc.Browserconn.ActiveSubscriptionsMutex.Lock() + hc.Browserconn.ActiveSubscriptions[queryId] = subscription + hc.Browserconn.ActiveSubscriptionsMutex.Unlock() + } +} + +func handleCompleteMessage(hc *common.HasuraConnection, queryId string) { + hc.Browserconn.ActiveSubscriptionsMutex.Lock() + delete(hc.Browserconn.ActiveSubscriptions, queryId) + hc.Browserconn.ActiveSubscriptionsMutex.Unlock() + log.Debugf("Subscription with Id %s finished by Hasura.", queryId) +} + +func handleConnectionAckMessage(hc *common.HasuraConnection, messageMap map[string]interface{}, fromHasuraToBrowserChannel *common.SafeChannel, fromBrowserToHasuraChannel *common.SafeChannel) { + log.Debugf("Received connection_ack") + //Hasura connection was initialized, now it's able to send new messages to Hasura + fromBrowserToHasuraChannel.UnfreezeChannel() + + //Avoid to send `connection_ack` to the browser when it's a reconnection + if hc.Browserconn.ConnAckSentToBrowser == false { + fromHasuraToBrowserChannel.Send(messageMap) + hc.Browserconn.ConnAckSentToBrowser = true + } + + go retransmiter.RetransmitSubscriptionStartMessages(hc, fromBrowserToHasuraChannel) +} diff --git a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go index 840e59d8d2..f254c45ff2 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go +++ b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go @@ -55,6 +55,7 @@ RangeLoop: //Identify type based on query string messageType := common.Query + lastReceivedDataSha256 := "" streamCursorField := "" streamCursorVariableName := "" var streamCursorInitialValue interface{} @@ -65,12 +66,16 @@ RangeLoop: if strings.HasPrefix(query, "subscription") { messageType = common.Subscription + browserConnection.ActiveSubscriptionsMutex.RLock() + existingSubscriptionData, queryIdExists := browserConnection.ActiveSubscriptions[queryId] + browserConnection.ActiveSubscriptionsMutex.RUnlock() + if queryIdExists { + lastReceivedDataSha256 = existingSubscriptionData.LastReceivedDataSha256 + } + if strings.Contains(query, "_stream(") && strings.Contains(query, "cursor: {") { messageType = common.Streaming - browserConnection.ActiveSubscriptionsMutex.RLock() - _, queryIdExists := browserConnection.ActiveSubscriptions[queryId] - browserConnection.ActiveSubscriptionsMutex.RUnlock() if !queryIdExists { streamCursorField, streamCursorVariableName, streamCursorInitialValue = common.GetStreamCursorPropsFromQuery(payload, query) @@ -110,6 +115,7 @@ RangeLoop: LastSeenOnHasuraConnection: hc.Id, JsonPatchSupported: jsonPatchSupported, Type: messageType, + LastReceivedDataSha256: lastReceivedDataSha256, } // log.Tracef("Current queries: %v", browserConnection.ActiveSubscriptions) browserConnection.ActiveSubscriptionsMutex.Unlock() diff --git a/bbb-graphql-middleware/internal/msgpatch/jsonpatch.go b/bbb-graphql-middleware/internal/msgpatch/jsonpatch.go index ac4ea65f3b..766b75feab 100644 --- a/bbb-graphql-middleware/internal/msgpatch/jsonpatch.go +++ b/bbb-graphql-middleware/internal/msgpatch/jsonpatch.go @@ -82,95 +82,60 @@ func ClearAllCaches() { } } -func PatchMessage(receivedMessage *map[string]interface{}, bConn *common.BrowserConnection) { +func PatchMessage(receivedMessage *map[string]interface{}, queryId string, dataKey string, dataAsJson []byte, bConn *common.BrowserConnection) { var receivedMessageMap = *receivedMessage - idValue, ok := receivedMessageMap["id"] - if !ok { - //Id does not exists in response Json - //It's not a subscription data + fileCacheDirPath, err := getSubscriptionCacheDirPath(bConn, queryId, true) + if err != nil { + log.Errorf("Error on get Client/Subscription cache path: %v", err) return } + filePath := fileCacheDirPath + dataKey + ".json" - payload, ok := receivedMessageMap["payload"].(map[string]interface{}) - if !ok { - //payload does not exists in response Json - //It's not a subscription data - return + lastContent, err := ioutil.ReadFile(filePath) + if err != nil { + //Last content doesn't exist, probably it's the first response } - - data, ok := payload["data"].(map[string]interface{}) - if !ok { - //payload.data does not exists in response Json - //It's not a subscription data - return - } - for key, value := range data { - currentData, ok := value.([]interface{}) - if !ok { - log.Errorf("Payload/Data/%s does not exists in response Json.", key) - return - } - - dataAsJsonString, err := json.Marshal(currentData) - if err != nil { - log.Errorf("Error on convert Payload/Data/%s.", key) - return - } - - fileCacheDirPath, err := getSubscriptionCacheDirPath(bConn, idValue.(string), true) - if err != nil { - log.Errorf("Error on get Client/Subscription cache path: %v", err) - return - } - filePath := fileCacheDirPath + key + ".json" - - lastContent, err := ioutil.ReadFile(filePath) - if err != nil { - //Last content doesn't exist, probably it's the first response - } - lastDataAsJsonString := string(lastContent) - if string(dataAsJsonString) == lastDataAsJsonString { - //Content didn't change, set message as null to avoid sending it to the browser - //This case is usual when the middleware reconnects with Hasura and receives the data again - *receivedMessage = nil - } else { - //Content was changed, creating json patch - //If data is small (< minLengthToPatch) it's not worth creating the patch - if lastDataAsJsonString != "" && len(string(dataAsJsonString)) > minLengthToPatch { - diffPatch, e := jsonpatch.CreatePatch([]byte(lastDataAsJsonString), []byte(dataAsJsonString)) - if e != nil { - log.Errorf("Error creating JSON patch:%v", e) - return - } - jsonDiffPatch, err := json.Marshal(diffPatch) - if err != nil { - log.Errorf("Error marshaling patch array:", err) - return - } - - //Use patch if the length is {minShrinkToUsePatch}% smaller than the original msg - if float64(len(string(jsonDiffPatch)))/float64(len(string(dataAsJsonString))) < minShrinkToUsePatch { - //Modify receivedMessage to include the Patch and remove the previous data - //The key of the original message is kept to avoid errors (Apollo-client expects to receive this prop) - receivedMessageMap["payload"] = map[string]interface{}{ - "data": map[string]interface{}{ - "patch": json.RawMessage(jsonDiffPatch), - key: json.RawMessage("[]"), - }, - } - *receivedMessage = receivedMessageMap - } + lastDataAsJsonString := string(lastContent) + if string(dataAsJson) == lastDataAsJsonString { + //Content didn't change, set message as null to avoid sending it to the browser + //This case is usual when the middleware reconnects with Hasura and receives the data again + *receivedMessage = nil + } else { + //Content was changed, creating json patch + //If data is small (< minLengthToPatch) it's not worth creating the patch + if lastDataAsJsonString != "" && len(string(dataAsJson)) > minLengthToPatch { + diffPatch, e := jsonpatch.CreatePatch([]byte(lastDataAsJsonString), []byte(dataAsJson)) + if e != nil { + log.Errorf("Error creating JSON patch:%v", e) + return + } + jsonDiffPatch, err := json.Marshal(diffPatch) + if err != nil { + log.Errorf("Error marshaling patch array:", err) + return } - //Store current result to be used to create json patch in the future - if lastDataAsJsonString != "" || len(string(dataAsJsonString)) > minLengthToPatch { - errWritingOutput := ioutil.WriteFile(filePath, []byte(dataAsJsonString), 0644) - if errWritingOutput != nil { - log.Errorf("Error on trying to write cache of json diff:", errWritingOutput) + //Use patch if the length is {minShrinkToUsePatch}% smaller than the original msg + if float64(len(string(jsonDiffPatch)))/float64(len(string(dataAsJson))) < minShrinkToUsePatch { + //Modify receivedMessage to include the Patch and remove the previous data + //The key of the original message is kept to avoid errors (Apollo-client expects to receive this prop) + receivedMessageMap["payload"] = map[string]interface{}{ + "data": map[string]interface{}{ + "patch": json.RawMessage(jsonDiffPatch), + dataKey: json.RawMessage("[]"), + }, } + *receivedMessage = receivedMessageMap } + } + //Store current result to be used to create json patch in the future + if lastDataAsJsonString != "" || len(string(dataAsJson)) > minLengthToPatch { + errWritingOutput := ioutil.WriteFile(filePath, []byte(dataAsJson), 0644) + if errWritingOutput != nil { + log.Errorf("Error on trying to write cache of json diff:", errWritingOutput) + } } } } From 11ef341aca00e428bd57fdc59f9b82e0237d6990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Fri, 2 Feb 2024 13:55:28 -0300 Subject: [PATCH 0312/1039] allow empty typed poll to be published --- .../chat-message/message-content/poll-content/component.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/poll-content/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/poll-content/component.tsx index 291c102e39..718398a368 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/poll-content/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/poll-content/component.tsx @@ -46,9 +46,6 @@ function assertAsMetadata(metadata: unknown): asserts metadata is Metadata { if (!Array.isArray((metadata as Metadata).answers)) { throw new Error('metadata.answers is not an array'); } - if ((metadata as Metadata).answers.length === 0) { - throw new Error('metadata.answers is empty'); - } } const ChatPollContent: React.FC = ({ From 25bee839c8813ea66adda83c3e51f53a18b1a268 Mon Sep 17 00:00:00 2001 From: Gabriel Luiz Porfirio Date: Fri, 2 Feb 2024 14:36:13 -0300 Subject: [PATCH 0313/1039] Update bigbluebutton-tests/playwright/options/options.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Anton Barboza de Sá --- bigbluebutton-tests/playwright/options/options.js | 1 - 1 file changed, 1 deletion(-) diff --git a/bigbluebutton-tests/playwright/options/options.js b/bigbluebutton-tests/playwright/options/options.js index 88ee53ac9d..2056abb144 100644 --- a/bigbluebutton-tests/playwright/options/options.js +++ b/bigbluebutton-tests/playwright/options/options.js @@ -71,7 +71,6 @@ class Options extends MultiUsers { }; await this.modPage.closeAllToastNotifications(); - await expect(modPageLocator).toHaveScreenshot('moderator-page-dark-mode.png', screenshotOptions); await openSettings(this.modPage); From a3d80b6424cbbbb77124b72c8020a4e3712d94f8 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Fri, 2 Feb 2024 14:37:32 -0300 Subject: [PATCH 0314/1039] Use crc32 instead of sha256 to store data checksum (#19563) --- bbb-graphql-middleware/internal/common/Hasher.go | 12 ------------ bbb-graphql-middleware/internal/common/types.go | 2 +- .../internal/hascli/conn/reader/reader.go | 9 +++++---- .../internal/hascli/conn/writer/writer.go | 6 +++--- 4 files changed, 9 insertions(+), 20 deletions(-) delete mode 100644 bbb-graphql-middleware/internal/common/Hasher.go diff --git a/bbb-graphql-middleware/internal/common/Hasher.go b/bbb-graphql-middleware/internal/common/Hasher.go deleted file mode 100644 index 8bab1665c4..0000000000 --- a/bbb-graphql-middleware/internal/common/Hasher.go +++ /dev/null @@ -1,12 +0,0 @@ -package common - -import ( - "crypto/sha256" - "fmt" -) - -func GenerateSha256(data []byte) string { - hasher := sha256.New() - hasher.Write(data) - return fmt.Sprintf("%x", hasher.Sum(nil)) -} diff --git a/bbb-graphql-middleware/internal/common/types.go b/bbb-graphql-middleware/internal/common/types.go index 1339ff2665..a7f850f293 100644 --- a/bbb-graphql-middleware/internal/common/types.go +++ b/bbb-graphql-middleware/internal/common/types.go @@ -25,7 +25,7 @@ type GraphQlSubscription struct { StreamCursorField string StreamCursorVariableName string StreamCursorCurrValue interface{} - LastReceivedDataSha256 string + LastReceivedDataChecksum uint32 JsonPatchSupported bool // indicate if client support Json Patch for this subscription LastSeenOnHasuraConnection string // id of the hasura connection that this query was active } diff --git a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go index 4c1bb0afa2..7775497d1c 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go +++ b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go @@ -8,6 +8,7 @@ import ( "github.com/iMDT/bbb-graphql-middleware/internal/hascli/retransmiter" "github.com/iMDT/bbb-graphql-middleware/internal/msgpatch" log "github.com/sirupsen/logrus" + "hash/crc32" "nhooyr.io/websocket/wsjson" "sync" ) @@ -96,13 +97,13 @@ func handleSubscriptionMessage(hc *common.HasuraConnection, messageMap map[strin if dataAsJson, err := json.Marshal(currentDataProp); err == nil { //Check whether ReceivedData is different from the LastReceivedData //Otherwise stop forwarding this message - dataSha256 := common.GenerateSha256(dataAsJson) - if subscription.LastReceivedDataSha256 == dataSha256 { + dataChecksum := crc32.ChecksumIEEE(dataAsJson) + if subscription.LastReceivedDataChecksum == dataChecksum { return false } - //Store LastReceivedData Sha256 - subscription.LastReceivedDataSha256 = dataSha256 + //Store LastReceivedData Checksum + subscription.LastReceivedDataChecksum = dataChecksum hc.Browserconn.ActiveSubscriptionsMutex.Lock() hc.Browserconn.ActiveSubscriptions[queryId] = subscription hc.Browserconn.ActiveSubscriptionsMutex.Unlock() diff --git a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go index f254c45ff2..4fc1914059 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go +++ b/bbb-graphql-middleware/internal/hascli/conn/writer/writer.go @@ -55,7 +55,7 @@ RangeLoop: //Identify type based on query string messageType := common.Query - lastReceivedDataSha256 := "" + var lastReceivedDataChecksum uint32 streamCursorField := "" streamCursorVariableName := "" var streamCursorInitialValue interface{} @@ -70,7 +70,7 @@ RangeLoop: existingSubscriptionData, queryIdExists := browserConnection.ActiveSubscriptions[queryId] browserConnection.ActiveSubscriptionsMutex.RUnlock() if queryIdExists { - lastReceivedDataSha256 = existingSubscriptionData.LastReceivedDataSha256 + lastReceivedDataChecksum = existingSubscriptionData.LastReceivedDataChecksum } if strings.Contains(query, "_stream(") && strings.Contains(query, "cursor: {") { @@ -115,7 +115,7 @@ RangeLoop: LastSeenOnHasuraConnection: hc.Id, JsonPatchSupported: jsonPatchSupported, Type: messageType, - LastReceivedDataSha256: lastReceivedDataSha256, + LastReceivedDataChecksum: lastReceivedDataChecksum, } // log.Tracef("Current queries: %v", browserConnection.ActiveSubscriptions) browserConnection.ActiveSubscriptionsMutex.Unlock() From e4a118cbbb09ed0f89315abd5be54580df059956 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Fri, 2 Feb 2024 15:09:56 -0300 Subject: [PATCH 0315/1039] Revert using go routine to process hasura messages --- bbb-graphql-middleware/internal/hascli/conn/reader/reader.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go index 4c1bb0afa2..9d47f0a626 100644 --- a/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go +++ b/bbb-graphql-middleware/internal/hascli/conn/reader/reader.go @@ -36,7 +36,7 @@ func HasuraConnectionReader(hc *common.HasuraConnection, fromHasuraToBrowserChan log.Tracef("received from hasura: %v", message) - go handleMessageReceivedFromHasura(hc, fromHasuraToBrowserChannel, fromBrowserToHasuraChannel, message) + handleMessageReceivedFromHasura(hc, fromHasuraToBrowserChannel, fromBrowserToHasuraChannel, message) } } From d44afd5cf25ee9e757515e1e4070e439eaa8ad19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Fri, 2 Feb 2024 16:44:35 -0300 Subject: [PATCH 0316/1039] fix(whiteboard): snapshot of current slide --- .../presentation-menu/component.jsx | 34 +++++++++---------- .../ui/components/whiteboard/component.jsx | 7 ++++ 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx index df0ac435b4..2ffdbaeb0f 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-menu/component.jsx @@ -88,9 +88,15 @@ const propTypes = { layoutContextDispatch: PropTypes.func.isRequired, isRTL: PropTypes.bool, tldrawAPI: PropTypes.shape({ - copySvg: PropTypes.func.isRequired, - getShapes: PropTypes.func.isRequired, - currentPageId: PropTypes.string.isRequired, + getSvg: PropTypes.func.isRequired, + currentPageShapes: PropTypes.arrayOf(PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + props: PropTypes.shape({ + w: PropTypes.number.isRequired, + h: PropTypes.number.isRequired, + }).isRequired, + })).isRequired, }), presentationDropdownItems: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, @@ -307,21 +313,15 @@ const PresentationMenu = (props) => { AppService.setDarkTheme(false); try { - const { copySvg, getShape, getShapes, currentPageId } = tldrawAPI; - // filter shapes that are inside the slide - const backgroundShape = getShape('slide-background-shape'); - const shapes = getShapes(currentPageId) - .filter((shape) => - shape.point[0] <= backgroundShape.size[0] && - shape.point[1] <= backgroundShape.size[1] && - shape.point[0] >= 0 && - shape.point[1] >= 0 - ); - const svgString = await copySvg(shapes.map((shape) => shape.id)); - const container = document.createElement('div'); - container.innerHTML = svgString; - const svgElem = container.firstChild; + const backgroundShape = tldrawAPI.currentPageShapes.find((s) => s.id === `shape:BG-${slideNum}`); + const shapes = tldrawAPI.currentPageShapes.filter( + (shape) => shape.x <= backgroundShape.props.w + && shape.y <= backgroundShape.props.h + && shape.x >= 0 + && shape.y >= 0, + ); + const svgElem = await tldrawAPI.getSvg(shapes.map((shape) => shape.id)); const width = svgElem?.width?.baseVal?.value ?? window.screen.width; const height = svgElem?.height?.baseVal?.value ?? window.screen.height; diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx index a9059b9a5e..2077fbb0e6 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx @@ -666,8 +666,15 @@ export default Whiteboard = React.memo(function Whiteboard(props) { : calcedZoom; }; + const setSafeTLDrawAPI = (api) => { + if (isMountedRef.current) { + setTldrawAPI(api); + } + }; + const handleTldrawMount = (editor) => { setTlEditor(editor); + setSafeTLDrawAPI(editor); editor?.user?.updateUserPreferences({ locale: language }); From 580155d70df8e8c8d62ef6d94755315f731c16ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Mon, 5 Feb 2024 10:05:19 -0300 Subject: [PATCH 0317/1039] Update bigbluebutton-html5/imports/ui/components/whiteboard/styles.js --- bigbluebutton-html5/imports/ui/components/whiteboard/styles.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js b/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js index bc0eb1fe21..232f69dd25 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/styles.js @@ -39,6 +39,9 @@ const TldrawV2GlobalStyle = createGlobalStyle` .tlui-menu-zone { visibility: hidden; } + #WhiteboardOptionButton { + opacity: 0.2; + } `} #presentationInnerWrapper > div:last-child { From e119d9d83dcb1e7b1021bb186520886e37e38b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Mon, 5 Feb 2024 11:02:47 -0300 Subject: [PATCH 0318/1039] fix(presentation): disable menu for not uploaded files --- .../presentation/presentation-uploader/component.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx index e09a061567..c02aaf4a98 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx @@ -841,9 +841,9 @@ class PresentationUploader extends Component { const isExporting = item?.exportToChatStatus === 'RUNNING'; const shouldDisableExportButton = (isExporting - || item.uploadInProgress + || !item.uploadCompleted || hasError - || disableActions) && item.uploadInProgress; + || disableActions); const formattedDownloadLabel = isExporting ? intl.formatMessage(intlMessages.exporting) From ca082e3705140d4af1a4836e7059a8130ba6bff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Mon, 5 Feb 2024 11:12:45 -0300 Subject: [PATCH 0319/1039] fix(whiteboard): safely set tldraw api --- .../imports/ui/components/whiteboard/component.jsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx index 881d861c4f..f6af82d1f3 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx @@ -708,6 +708,12 @@ export default Whiteboard = React.memo(function Whiteboard(props) { presentationAreaHeight, ]); + const setSafeTLDrawAPI = (api) => { + if (isMountedRef.current) { + setTldrawAPI(api); + } + }; + const handleTldrawMount = (editor) => { setTlEditor(editor); setSafeTLDrawAPI(editor); From 0715337257ca078a8fee163cc52aa07edade75f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= Date: Mon, 5 Feb 2024 11:18:31 -0300 Subject: [PATCH 0320/1039] Variable name swapping and requested changes --- .../events/StoreExportJobInRedisPresAnnEvent.scala | 6 +++--- .../endpoint/redis/ExportAnnotationsActor.scala | 2 +- .../bigbluebutton/common2/msgs/WhiteboardMsgs.scala | 2 +- bbb-export-annotations/workers/collector.js | 6 +++--- bbb-export-annotations/workers/notifier.js | 8 ++++---- bbb-export-annotations/workers/process.js | 4 ++-- .../presentation-content/component.tsx | 11 +++++++++-- bigbluebutton-html5/public/locales/en.json | 1 + 8 files changed, 24 insertions(+), 16 deletions(-) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/StoreExportJobInRedisPresAnnEvent.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/StoreExportJobInRedisPresAnnEvent.scala index 6a8381eaca..95fa38090c 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/StoreExportJobInRedisPresAnnEvent.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/record/events/StoreExportJobInRedisPresAnnEvent.scala @@ -27,8 +27,8 @@ class StoreExportJobInRedisPresAnnEvent extends AbstractPresentationWithAnnotati setEvent("StoreExportJobInRedisPresAnnEvent") - def setFileNameToPath(fileNameToPath: String) { - eventMap.put(FILE_NAME_TO_PATH, fileNameToPath) + def setserverSideFilename(serverSideFilename: String) { + eventMap.put(SERVER_SIDE_FILENAME, serverSideFilename) } def setJobId(jobId: String) { @@ -72,7 +72,7 @@ object StoreExportJobInRedisPresAnnEvent { protected final val JOB_ID = "jobId" protected final val JOB_TYPE = "jobType" protected final val FILENAME = "filename" - protected final val FILE_NAME_TO_PATH = "fileNameToPath" + protected final val SERVER_SIDE_FILENAME = "serverSideFilename" protected final val PRES_ID = "presId" protected final val PRES_LOCATION = "presLocation" protected final val ALL_PAGES = "allPages" diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/ExportAnnotationsActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/ExportAnnotationsActor.scala index 4b838ee66e..bb9a229cc5 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/ExportAnnotationsActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/ExportAnnotationsActor.scala @@ -73,7 +73,7 @@ class ExportAnnotationsActor( private def handleStoreExportJobInRedisSysMsg(msg: StoreExportJobInRedisSysMsg) { val ev = new StoreExportJobInRedisPresAnnEvent() - ev.setFileNameToPath(msg.body.exportJob.fileNameToPath) + ev.setserverSideFilename(msg.body.exportJob.serverSideFilename) ev.setJobId(msg.body.exportJob.jobId) ev.setJobType(msg.body.exportJob.jobType) ev.setFilename(msg.body.exportJob.filename) diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala index 3545cb30db..19ab09c446 100755 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/WhiteboardMsgs.scala @@ -22,7 +22,7 @@ case class ExportJob( jobId: String, jobType: String, filename: String, - fileNameToPath: String, + serverSideFilename: String, presId: String, presLocation: String, allPages: Boolean, diff --git a/bbb-export-annotations/workers/collector.js b/bbb-export-annotations/workers/collector.js index bf5ce07c6f..5feb1a0ef9 100644 --- a/bbb-export-annotations/workers/collector.js +++ b/bbb-export-annotations/workers/collector.js @@ -129,9 +129,9 @@ async function collectSharedNotes(retries = 3) { const padId = exportJob.presId; const notesFormat = 'pdf'; - const fileNameToPath = `${sanitize(exportJob.fileNameToPath.replace(/\s/g, '_'))}.${notesFormat}`; + const serverSideFilename = `${sanitize(exportJob.serverSideFilename.replace(/\s/g, '_'))}.${notesFormat}`; const notes_endpoint = `${config.bbbPadsAPI}/p/${padId}/export/${notesFormat}`; - const filePath = path.join(dropbox, fileNameToPath); + const filePath = path.join(dropbox, serverSideFilename); const finishedDownload = promisify(stream.finished); const writer = fs.createWriteStream(filePath); @@ -157,7 +157,7 @@ async function collectSharedNotes(retries = 3) { } } - const notifier = new WorkerStarter({jobType, jobId, fileNameToPath, filename: exportJob.filename}); + const notifier = new WorkerStarter({jobType, jobId, serverSideFilename, filename: exportJob.filename}); notifier.notify(); } diff --git a/bbb-export-annotations/workers/notifier.js b/bbb-export-annotations/workers/notifier.js index c1084c6c1f..c7764bd6ea 100644 --- a/bbb-export-annotations/workers/notifier.js +++ b/bbb-export-annotations/workers/notifier.js @@ -8,7 +8,7 @@ const path = require('path'); const {NewPresFileAvailableMsg} = require('../lib/utils/message-builder'); const {workerData} = require('worker_threads'); -const [jobType, jobId, fileNameToPath] = [workerData.jobType, workerData.jobId, workerData.fileNameToPath]; +const [jobType, jobId, serverSideFilename] = [workerData.jobType, workerData.jobId, workerData.serverSideFilename]; const logger = new Logger('presAnn Notifier Worker'); @@ -30,7 +30,7 @@ async function notifyMeetingActor() { const link = path.join('presentation', exportJob.parentMeetingId, exportJob.parentMeetingId, - exportJob.presId, 'pdf', jobId, fileNameToPath); + exportJob.presId, 'pdf', jobId, serverSideFilename); const notification = new NewPresFileAvailableMsg(exportJob, link); @@ -64,10 +64,10 @@ async function upload(filePath) { if (jobType == 'PresentationWithAnnotationDownloadJob') { notifyMeetingActor(); } else if (jobType == 'PresentationWithAnnotationExportJob') { - const filePath = `${exportJob.presLocation}/pdfs/${jobId}/${fileNameToPath}`; + const filePath = `${exportJob.presLocation}/pdfs/${jobId}/${serverSideFilename}`; upload(filePath); } else if (jobType == 'PadCaptureJob') { - const filePath = `${dropbox}/${fileNameToPath}`; + const filePath = `${dropbox}/${serverSideFilename}`; upload(filePath); } else { logger.error(`Notifier received unknown job type ${jobType}`); diff --git a/bbb-export-annotations/workers/process.js b/bbb-export-annotations/workers/process.js index e0efd44bf2..44748e71ba 100644 --- a/bbb-export-annotations/workers/process.js +++ b/bbb-export-annotations/workers/process.js @@ -886,7 +886,7 @@ async function process_presentation_annotations() { fs.mkdirSync(outputDir, {recursive: true}); } - const filename_with_extension = `${sanitize(exportJob.fileNameToPath.replace(/\s/g, '_'))}.pdf`; + const filename_with_extension = `${sanitize(exportJob.serverSideFilename.replace(/\s/g, '_'))}.pdf`; const mergePDFs = [ '-dNOPAUSE', @@ -905,7 +905,7 @@ async function process_presentation_annotations() { logger.info(`Saved PDF at ${outputDir}/${jobId}/${filename_with_extension}`); const notifier = new WorkerStarter({jobType: exportJob.jobType, jobId, - fileNameToPath: filename_with_extension, filename: exportJob.filename}); + serverSideFilename: filename_with_extension, filename: exportJob.filename}); notifier.notify(); await client.disconnect(); } diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/presentation-content/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/presentation-content/component.tsx index bf01a85998..2a6cfacf80 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/presentation-content/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/presentation-content/component.tsx @@ -35,6 +35,10 @@ const intlMessages = defineMessages({ id: 'app.presentationUploader.export.notAccessibleWarning', description: 'used for indicating that a link may be not accessible', }, + withWhiteboardAnnotations: { + id: 'app.presentationUploader.withWhiteboardAnnotations', + description: 'used for indicating that presentation has annotations', + }, }); const ChatMessagePresentationContent: React.FC = ({ @@ -48,13 +52,16 @@ const ChatMessagePresentationContent: React.FC - {presentationData.filename} + + {presentationData.filename} + {intl.formatMessage(intlMessages.withWhiteboardAnnotations)} + {intl.formatMessage(intlMessages.download)} diff --git a/bigbluebutton-html5/public/locales/en.json b/bigbluebutton-html5/public/locales/en.json index 11871d2242..38711753f9 100755 --- a/bigbluebutton-html5/public/locales/en.json +++ b/bigbluebutton-html5/public/locales/en.json @@ -300,6 +300,7 @@ "app.presentationUploader.exportCurrentStatePresentation": "Send out a download link for the presentation including whiteboard annotations", "app.presentationUploader.enableOriginalPresentationDownload": "Enable download of the presentation ({0})", "app.presentationUploader.disableOriginalPresentationDownload": "Disable download of the original presentation ({0})", + "app.presentationUploader.withWhiteboardAnnotations": " (with whiteboard annotations)", "app.presentationUploader.dropdownExportOptions": "Export options", "app.presentationUploader.export.linkAvailable": "Link for downloading {0} available on the public chat.", "app.presentationUploader.export.downloadButtonAvailable": "Download button for presentation {0} is available.", From 3e72e2d082ab3d266497b871ba19622da94a48ac Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Mon, 5 Feb 2024 11:31:31 -0300 Subject: [PATCH 0321/1039] Introducing Session Persistence Post-Meeting (#19534) * Add Session Cleanup Delay Configuration After Meeting End * Makes 60minutes as default sessionsCleanupDelayInMinutes --- .../core/BigBlueButtonActor.scala | 5 +- ...EjectUserFromBreakoutInternalMsgHdlr.scala | 2 +- .../voice/UserLeftVoiceConfEvtMsgHdlr.scala | 2 +- .../bigbluebutton/core/db/MeetingDAO.scala | 74 +++++++++++------- .../org/bigbluebutton/core/db/UserDAO.scala | 16 +++- .../core/models/RegisteredUsers.scala | 2 +- .../bigbluebutton/core/models/Users2x.scala | 4 +- .../org/bigbluebutton/api/MeetingService.java | 76 +++++++++++++++---- .../api/domain/UserSessionBasicData.java | 30 ++++++++ .../model/validator/GuestPolicyValidator.java | 2 +- .../model/validator/UserSessionValidator.java | 2 +- .../api/service/SessionService.java | 2 +- bbb-graphql-client-test/src/Auth.js | 8 +- bbb-graphql-server/bbb_schema.sql | 4 +- bbb-graphql-server/metadata/actions.yaml | 2 +- .../tables/public_v_meeting.yaml | 4 +- .../tables/public_v_user_current.yaml | 2 +- .../tables/public_v_user_guest.yaml | 2 +- .../grails-app/conf/bigbluebutton.properties | 5 ++ .../grails-app/conf/spring/resources.xml | 1 + .../web/controllers/ApiController.groovy | 4 +- .../controllers/ConnectionController.groovy | 38 ++++++++-- 22 files changed, 215 insertions(+), 72 deletions(-) create mode 100755 bbb-common-web/src/main/java/org/bigbluebutton/api/domain/UserSessionBasicData.java diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/BigBlueButtonActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/BigBlueButtonActor.scala index 3fb3c5c810..4a22395620 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/BigBlueButtonActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/BigBlueButtonActor.scala @@ -189,9 +189,10 @@ class BigBlueButtonActor( context.stop(m.actorRef) } - MeetingDAO.delete(msg.meetingId) + // MeetingDAO.delete(msg.meetingId) + MeetingDAO.setMeetingEnded(msg.meetingId) // Removing the meeting is enough, all other tables has "ON DELETE CASCADE" - // UserDAO.deleteAllFromMeeting(msg.meetingId) + // UserDAO.softDeleteAllFromMeeting(msg.meetingId) // MeetingRecordingDAO.updateStopped(msg.meetingId, "") //Remove ColorPicker idx of the meeting diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/EjectUserFromBreakoutInternalMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/EjectUserFromBreakoutInternalMsgHdlr.scala index 679403715b..d177f4ad21 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/EjectUserFromBreakoutInternalMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/EjectUserFromBreakoutInternalMsgHdlr.scala @@ -30,7 +30,7 @@ trait EjectUserFromBreakoutInternalMsgHdlr { ) //TODO inform reason - UserDAO.delete(registeredUser.id) + UserDAO.softDelete(registeredUser.id) // send a system message to force disconnection Sender.sendDisconnectClientSysMsg(msg.breakoutId, registeredUser.id, msg.ejectedBy, msg.reasonCode, outGW) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/UserLeftVoiceConfEvtMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/UserLeftVoiceConfEvtMsgHdlr.scala index 51a492b149..153e83a76f 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/UserLeftVoiceConfEvtMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/UserLeftVoiceConfEvtMsgHdlr.scala @@ -40,7 +40,7 @@ trait UserLeftVoiceConfEvtMsgHdlr { UsersApp.guestWaitingLeft(liveMeeting, user.intId, outGW) } Users2x.remove(liveMeeting.users2x, user.intId) - UserDAO.delete(user.intId) + UserDAO.softDelete(user.intId) VoiceApp.removeUserFromVoiceConf(liveMeeting, outGW, msg.body.voiceUserId) } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/MeetingDAO.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/MeetingDAO.scala index 08b0f0ccdd..3163a6c937 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/MeetingDAO.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/MeetingDAO.scala @@ -24,7 +24,8 @@ case class MeetingDbModel( bannerText: Option[String], bannerColor: Option[String], createdTime: Long, - durationInSeconds: Int + durationInSeconds: Int, + endedAt: Option[java.sql.Timestamp], ) class MeetingDbTableDef(tag: Tag) extends Table[MeetingDbModel](tag, None, "meeting") { @@ -45,7 +46,8 @@ class MeetingDbTableDef(tag: Tag) extends Table[MeetingDbModel](tag, None, "meet bannerText, bannerColor, createdTime, - durationInSeconds + durationInSeconds, + endedAt ) <> (MeetingDbModel.tupled, MeetingDbModel.unapply) val meetingId = column[String]("meetingId", O.PrimaryKey) val extId = column[String]("extId") @@ -64,6 +66,7 @@ class MeetingDbTableDef(tag: Tag) extends Table[MeetingDbModel](tag, None, "meet val bannerColor = column[Option[String]]("bannerColor") val createdTime = column[Long]("createdTime") val durationInSeconds = column[Int]("durationInSeconds") + val endedAt = column[Option[java.sql.Timestamp]]("endedAt") } object MeetingDAO { @@ -84,39 +87,40 @@ object MeetingDAO { learningDashboardAccessToken = meetingProps.password.learningDashboardAccessToken, logoutUrl = meetingProps.systemProps.logoutUrl, customLogoUrl = meetingProps.systemProps.customLogoURL match { - case "" => None + case "" => None case logoUrl => Some(logoUrl) }, bannerText = meetingProps.systemProps.bannerText match { - case "" => None + case "" => None case bannerText => Some(bannerText) }, bannerColor = meetingProps.systemProps.bannerColor match { - case "" => None + case "" => None case bannerColor => Some(bannerColor) }, createdTime = meetingProps.durationProps.createdTime, - durationInSeconds = meetingProps.durationProps.duration * 60 + durationInSeconds = meetingProps.durationProps.duration * 60, + endedAt = None ) ) ).onComplete { - case Success(rowsAffected) => { - DatabaseConnection.logger.debug(s"$rowsAffected row(s) inserted in Meeting table!") - ChatDAO.insert(meetingProps.meetingProp.intId, GroupChatApp.createDefaultPublicGroupChat()) - MeetingUsersPoliciesDAO.insert(meetingProps.meetingProp.intId, meetingProps.usersProp) - MeetingLockSettingsDAO.insert(meetingProps.meetingProp.intId, meetingProps.lockSettingsProps) - MeetingMetadataDAO.insert(meetingProps.meetingProp.intId, meetingProps.metadataProp) - MeetingRecordingPoliciesDAO.insert(meetingProps.meetingProp.intId, meetingProps.recordProp) - MeetingVoiceDAO.insert(meetingProps.meetingProp.intId, meetingProps.voiceProp) - MeetingWelcomeDAO.insert(meetingProps.meetingProp.intId, meetingProps.welcomeProp) - MeetingGroupDAO.insert(meetingProps.meetingProp.intId, meetingProps.groups) - MeetingBreakoutDAO.insert(meetingProps.meetingProp.intId, meetingProps.breakoutProps) - TimerDAO.insert(meetingProps.meetingProp.intId) - LayoutDAO.insert(meetingProps.meetingProp.intId, meetingProps.usersProp.meetingLayout) - MeetingClientSettingsDAO.insert(meetingProps.meetingProp.intId, JsonUtils.mapToJson(clientSettings)) - } - case Failure(e) => DatabaseConnection.logger.error(s"Error inserting Meeting: $e") + case Success(rowsAffected) => { + DatabaseConnection.logger.debug(s"$rowsAffected row(s) inserted in Meeting table!") + ChatDAO.insert(meetingProps.meetingProp.intId, GroupChatApp.createDefaultPublicGroupChat()) + MeetingUsersPoliciesDAO.insert(meetingProps.meetingProp.intId, meetingProps.usersProp) + MeetingLockSettingsDAO.insert(meetingProps.meetingProp.intId, meetingProps.lockSettingsProps) + MeetingMetadataDAO.insert(meetingProps.meetingProp.intId, meetingProps.metadataProp) + MeetingRecordingPoliciesDAO.insert(meetingProps.meetingProp.intId, meetingProps.recordProp) + MeetingVoiceDAO.insert(meetingProps.meetingProp.intId, meetingProps.voiceProp) + MeetingWelcomeDAO.insert(meetingProps.meetingProp.intId, meetingProps.welcomeProp) + MeetingGroupDAO.insert(meetingProps.meetingProp.intId, meetingProps.groups) + MeetingBreakoutDAO.insert(meetingProps.meetingProp.intId, meetingProps.breakoutProps) + TimerDAO.insert(meetingProps.meetingProp.intId) + LayoutDAO.insert(meetingProps.meetingProp.intId, meetingProps.usersProp.meetingLayout) + MeetingClientSettingsDAO.insert(meetingProps.meetingProp.intId, JsonUtils.mapToJson(clientSettings)) } + case Failure(e) => DatabaseConnection.logger.error(s"Error inserting Meeting: $e") + } } def updateMeetingDurationByParentMeeting(parentMeetingId: String, newDurationInSeconds: Int) = { @@ -131,9 +135,9 @@ object MeetingDAO { .map(u => u.durationInSeconds) .update(newDurationInSeconds) ).onComplete { - case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated durationInSeconds on Meeting table") - case Failure(e) => DatabaseConnection.logger.debug(s"Error updating durationInSeconds on Meeting: $e") - } + case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated durationInSeconds on Meeting table") + case Failure(e) => DatabaseConnection.logger.debug(s"Error updating durationInSeconds on Meeting: $e") + } } def delete(meetingId: String) = { @@ -142,9 +146,23 @@ object MeetingDAO { .filter(_.meetingId === meetingId) .delete ).onComplete { - case Success(rowsAffected) => DatabaseConnection.logger.debug(s"Meeting ${meetingId} deleted") - case Failure(e) => DatabaseConnection.logger.debug(s"Error deleting meeting ${meetingId}: $e") - } + case Success(rowsAffected) => DatabaseConnection.logger.debug(s"Meeting ${meetingId} deleted") + case Failure(e) => DatabaseConnection.logger.debug(s"Error deleting meeting ${meetingId}: $e") + } } + def setMeetingEnded(meetingId: String) = { + + UserDAO.softDeleteAllFromMeeting(meetingId) + + DatabaseConnection.db.run( + TableQuery[MeetingDbTableDef] + .filter(_.meetingId === meetingId) + .map(a => (a.endedAt)) + .update(Some(new java.sql.Timestamp(System.currentTimeMillis()))) + ).onComplete { + case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated endedAt=now() on Meeting table!") + case Failure(e) => DatabaseConnection.logger.debug(s"Error updating endedAt=now() Meeting: $e") + } + } } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/UserDAO.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/UserDAO.scala index 9fa80d8403..bbcb1f0c77 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/UserDAO.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/UserDAO.scala @@ -135,7 +135,7 @@ object UserDAO { } - def delete(intId: String) = { + def softDelete(intId: String) = { DatabaseConnection.db.run( TableQuery[UserDbTableDef] .filter(_.userId === intId) @@ -147,7 +147,19 @@ object UserDAO { } } - def deleteAllFromMeeting(meetingId: String) = { + def softDeleteAllFromMeeting(meetingId: String) = { + DatabaseConnection.db.run( + TableQuery[UserDbTableDef] + .filter(_.meetingId === meetingId) + .map(u => (u.loggedOut)) + .update((true)) + ).onComplete { + case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated loggedOut=true on user table!") + case Failure(e) => DatabaseConnection.logger.error(s"Error updating loggedOut=true user: $e") + } + } + + def permanentlyDeleteAllFromMeeting(meetingId: String) = { DatabaseConnection.db.run( TableQuery[UserDbTableDef] .filter(_.meetingId === meetingId) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/RegisteredUsers.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/RegisteredUsers.scala index 65af0cdb10..abd9b479f7 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/RegisteredUsers.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/RegisteredUsers.scala @@ -122,7 +122,7 @@ object RegisteredUsers { u } else { users.delete(ejectedUser.id) -// UserDAO.delete(ejectedUser) it's being removed in User2x already +// UserDAO.softDelete(ejectedUser) it's being removed in User2x already ejectedUser } } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/Users2x.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/Users2x.scala index 1fb1c62f96..5f093b6fcf 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/Users2x.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/Users2x.scala @@ -27,7 +27,7 @@ object Users2x { } def remove(users: Users2x, intId: String): Option[UserState] = { - //UserDAO.delete(intId) + //UserDAO.softDelete(intId) users.remove(intId) } @@ -125,7 +125,7 @@ object Users2x { _ <- users.remove(intId) ejectedUser <- users.removeFromCache(intId) } yield { - // UserDAO.delete(intId) --it will keep the user on Db + // UserDAO.softDelete(intId) --it will keep the user on Db ejectedUser } } diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java index 7961d705d4..1dcaf6df2c 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java @@ -75,6 +75,7 @@ public class MeetingService implements MessageListener { */ private final ConcurrentMap meetings; private final ConcurrentMap sessions; + private final ConcurrentMap removedSessions; private RecordingService recordingService; private LearningDashboardService learningDashboardService; @@ -88,6 +89,7 @@ public class MeetingService implements MessageListener { private long usersTimeout; private long waitingGuestUsersTimeout; + private int sessionsCleanupDelayInMinutes; private long enteredUsersTimeout; private ParamsProcessorUtil paramsProcessorUtil; @@ -100,6 +102,7 @@ public class MeetingService implements MessageListener { public MeetingService() { meetings = new ConcurrentHashMap(8, 0.9f, 1); sessions = new ConcurrentHashMap(8, 0.9f, 1); + removedSessions = new ConcurrentHashMap(8, 0.9f, 1); uploadAuthzTokens = new HashMap(); } @@ -149,12 +152,16 @@ public class MeetingService implements MessageListener { return null; } - public UserSession getUserSessionWithAuthToken(String token) { + public UserSession getUserSessionWithSessionToken(String token) { return sessions.get(token); } + public UserSessionBasicData getRemovedUserSessionWithSessionToken(String sessionToken) { + return removedSessions.get(sessionToken); + } + public Boolean getAllowRequestsWithoutSession(String token) { - UserSession us = getUserSessionWithAuthToken(token); + UserSession us = getUserSessionWithSessionToken(token); if (us == null) { return false; } else { @@ -164,12 +171,21 @@ public class MeetingService implements MessageListener { } } - public UserSession removeUserSessionWithAuthToken(String token) { - UserSession user = sessions.remove(token); - if (user != null) { - log.debug("Found user {} token={} to meeting {}", user.fullname, token, user.meetingID); + public void removeUserSessionWithSessionToken(String token) { + log.debug("Removing token={}", token); + UserSession us = getUserSessionWithSessionToken(token); + if (us != null) { + log.debug("Found user {} token={} to meeting {}", us.fullname, token, us.meetingID); + + UserSessionBasicData removedUser = new UserSessionBasicData(); + removedUser.meetingId = us.meetingID; + removedUser.userId = us.internalUserId; + removedUser.sessionToken = us.authToken; + removedSessions.put(token, removedUser); + sessions.remove(token); + } else { + log.debug("Not found token={}", token); } - return user; } /** @@ -295,16 +311,40 @@ public class MeetingService implements MessageListener { notifier.sendUploadFileTooLargeMessage(presUploadToken, uploadedFileSize, maxUploadFileSize); } - private void removeUserSessions(String meetingId) { - Iterator> iterator = sessions.entrySet().iterator(); - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - UserSession userSession = entry.getValue(); - + private void removeUserSessionsFromMeeting(String meetingId) { + for (String token : sessions.keySet()) { + UserSession userSession = sessions.get(token); if (userSession.meetingID.equals(meetingId)) { - iterator.remove(); + System.out.println(token + " = " + userSession.authToken); + removeUserSessionWithSessionToken(token); } } + + scheduleRemovedSessionsCleanUp(meetingId); + } + + private void scheduleRemovedSessionsCleanUp(String meetingId) { + Calendar cleanUpDelayCalendar = Calendar.getInstance(); + cleanUpDelayCalendar.add(Calendar.MINUTE, sessionsCleanupDelayInMinutes); + + log.debug("Sessions for meeting={} will be removed within {} minutes.", meetingId, sessionsCleanupDelayInMinutes); + new java.util.Timer().schedule( + new java.util.TimerTask() { + @Override + public void run() { + Iterator> iterator = removedSessions.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + UserSessionBasicData removedUserSession = entry.getValue(); + + if (removedUserSession.meetingId.equals(meetingId)) { + log.debug("Removed user {} session for meeting {}.",removedUserSession.userId, removedUserSession.meetingId); + iterator.remove(); + } + } + } + }, cleanUpDelayCalendar.getTime() + ); } private void destroyMeeting(String meetingId) { @@ -703,7 +743,7 @@ public class MeetingService implements MessageListener { } destroyMeeting(m.getInternalId()); meetings.remove(m.getInternalId()); - removeUserSessions(m.getInternalId()); + removeUserSessionsFromMeeting(m.getInternalId()); Map logData = new HashMap<>(); logData.put("meetingId", m.getInternalId()); @@ -1111,7 +1151,7 @@ public class MeetingService implements MessageListener { user.setRole(message.role); String sessionToken = getTokenByUserId(user.getInternalUserId()); if (sessionToken != null) { - UserSession userSession = getUserSessionWithAuthToken(sessionToken); + UserSession userSession = getUserSessionWithSessionToken(sessionToken); userSession.role = message.role; sessions.replace(sessionToken, userSession); } @@ -1363,6 +1403,10 @@ public class MeetingService implements MessageListener { waitingGuestUsersTimeout = value; } + public void setSessionsCleanupDelayInMinutes(int value) { + sessionsCleanupDelayInMinutes = value; + } + public void setEnteredUsersTimeout(long value) { enteredUsersTimeout = value; } diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/UserSessionBasicData.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/UserSessionBasicData.java new file mode 100755 index 0000000000..dc66719624 --- /dev/null +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/UserSessionBasicData.java @@ -0,0 +1,30 @@ +/** +* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/ +* +* Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below). +* +* This program is free software; you can redistribute it and/or modify it under the +* terms of the GNU Lesser General Public License as published by the Free Software +* Foundation; either version 3.0 of the License, or (at your option) any later +* version. +* +* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY +* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +* +* You should have received a copy of the GNU Lesser General Public License along +* with BigBlueButton; if not, see . +* +*/ + +package org.bigbluebutton.api.domain; + +public class UserSessionBasicData { + public String sessionToken = null; + public String userId = null; + public String meetingId = null; + + public String toString() { + return meetingId + " " + userId + " " + sessionToken; + } +} diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/model/validator/GuestPolicyValidator.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/model/validator/GuestPolicyValidator.java index 0cc0cb4067..a049d4d171 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/model/validator/GuestPolicyValidator.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/model/validator/GuestPolicyValidator.java @@ -22,7 +22,7 @@ public class GuestPolicyValidator implements ConstraintValidator { console.log('user_current', curr); - if(curr.loggedOut) { + if(curr.meeting.ended) { + return
+ {curr.meeting.name} +

+ Meeting has ended.
+ } else if(curr.ejected) { return
{curr.meeting.name}

diff --git a/bbb-graphql-server/bbb_schema.sql b/bbb-graphql-server/bbb_schema.sql index 19f68cafa8..46ed7ce952 100644 --- a/bbb-graphql-server/bbb_schema.sql +++ b/bbb-graphql-server/bbb_schema.sql @@ -25,9 +25,11 @@ create table "meeting" ( "bannerText" text, "bannerColor" varchar(50), "createdTime" bigint, - "durationInSeconds" integer + "durationInSeconds" integer, + "endedAt" timestamp with time zone ); ALTER TABLE "meeting" ADD COLUMN "createdAt" timestamp with time zone GENERATED ALWAYS AS (to_timestamp("createdTime"::double precision / 1000)) STORED; +ALTER TABLE "meeting" ADD COLUMN "ended" boolean GENERATED ALWAYS AS ("endedAt" is not null) STORED; create index "idx_meeting_extId" on "meeting"("extId"); diff --git a/bbb-graphql-server/metadata/actions.yaml b/bbb-graphql-server/metadata/actions.yaml index 8cf4d4bdb3..390656cbcb 100644 --- a/bbb-graphql-server/metadata/actions.yaml +++ b/bbb-graphql-server/metadata/actions.yaml @@ -389,7 +389,7 @@ actions: kind: synchronous handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}' permissions: - - role: pre_join_bbb_client + - role: not_joined_bbb_client - role: bbb_client - name: userLeaveMeeting definition: diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_meeting.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_meeting.yaml index a56af019ae..085c8c4f11 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_meeting.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_meeting.yaml @@ -149,6 +149,7 @@ select_permissions: - html5InstanceId - isBreakout - logoutUrl + - ended - maxPinnedCameras - meetingCameraCap - meetingId @@ -159,13 +160,14 @@ select_permissions: filter: meetingId: _eq: X-Hasura-MeetingId - - role: pre_join_bbb_client + - role: not_joined_bbb_client permission: columns: - bannerColor - bannerText - customLogoUrl - logoutUrl + - ended - meetingId - name filter: diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_current.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_current.yaml index 993b22335d..6a0314fa4f 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_current.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_current.yaml @@ -172,7 +172,7 @@ select_permissions: filter: userId: _eq: X-Hasura-UserId - - role: pre_join_bbb_client + - role: not_joined_bbb_client permission: columns: - authToken diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_guest.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_guest.yaml index 3aed1906ef..a9f2a83645 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_guest.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_guest.yaml @@ -34,7 +34,7 @@ select_permissions: - meetingId: _eq: X-Hasura-ModeratorInMeeting allow_aggregations: true - - role: pre_join_bbb_client + - role: not_joined_bbb_client permission: columns: - guestLobbyMessage diff --git a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties index 027b70bbd4..b5b851ad27 100644 --- a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties +++ b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties @@ -304,6 +304,11 @@ defaultHTML5ClientUrl=${bigbluebutton.web.serverURL}/html5client/join # Using `serverURL` as default, so `https` will be automatically replaced by `wss` graphqlWebsocketUrl=${bigbluebutton.web.serverURL}/v1/graphql +# This parameter defines the duration (in minutes) to wait before removing user sessions after a meeting has ended. +# During this delay, users can still access information indicating that the "Meeting has ended". +# Setting this value to 0 will result in the sessions being kept alive indefinitely (permanent availability). +sessionsCleanupDelayInMinutes=60 + useDefaultLogo=false defaultLogoURL=${bigbluebutton.web.serverURL}/images/logo.png diff --git a/bigbluebutton-web/grails-app/conf/spring/resources.xml b/bigbluebutton-web/grails-app/conf/spring/resources.xml index 461b35a1bf..2ef1102312 100755 --- a/bigbluebutton-web/grails-app/conf/spring/resources.xml +++ b/bigbluebutton-web/grails-app/conf/spring/resources.xml @@ -53,6 +53,7 @@ with BigBlueButton; if not, see . + diff --git a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy index a3a4047e04..d4c91363b6 100755 --- a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy +++ b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy @@ -1112,7 +1112,7 @@ class ApiController { if(validationResponse == null) { String sessionToken = sanitizeSessionToken(params.sessionToken) - UserSession us = meetingService.removeUserSessionWithAuthToken(sessionToken) + UserSession us = meetingService.removeUserSessionWithSessionToken(sessionToken) Map logData = new HashMap(); logData.put("meetingid", us.meetingID); logData.put("extMeetingid", us.externMeetingID); @@ -1803,7 +1803,7 @@ class ApiController { return null } - UserSession us = meetingService.getUserSessionWithAuthToken(token) + UserSession us = meetingService.getUserSessionWithSessionToken(token) if (us == null) { log.info("Cannot find UserSession for token ${token}") } diff --git a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ConnectionController.groovy b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ConnectionController.groovy index 88c07e3776..e522b92804 100755 --- a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ConnectionController.groovy +++ b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ConnectionController.groovy @@ -23,6 +23,7 @@ import org.bigbluebutton.api.MeetingService import org.bigbluebutton.api.domain.Meeting import org.bigbluebutton.api.domain.UserSession import org.bigbluebutton.api.domain.User +import org.bigbluebutton.api.domain.UserSessionBasicData import org.bigbluebutton.api.util.ParamsUtil import org.bigbluebutton.api.ParamsProcessorUtil import java.nio.charset.StandardCharsets @@ -36,7 +37,7 @@ class ConnectionController { try { def uri = request.getHeader("x-original-uri") def sessionToken = ParamsUtil.getSessionToken(uri) - UserSession userSession = meetingService.getUserSessionWithAuthToken(sessionToken) + UserSession userSession = meetingService.getUserSessionWithSessionToken(sessionToken) Boolean allowRequestsWithoutSession = meetingService.getAllowRequestsWithoutSession(sessionToken) Boolean isSessionTokenInvalid = !session[sessionToken] && !allowRequestsWithoutSession @@ -67,7 +68,7 @@ class ConnectionController { String sessionToken = request.getHeader("x-session-token") - UserSession userSession = meetingService.getUserSessionWithAuthToken(sessionToken) + UserSession userSession = meetingService.getUserSessionWithSessionToken(sessionToken) Boolean allowRequestsWithoutSession = meetingService.getAllowRequestsWithoutSession(sessionToken) Boolean isSessionTokenInvalid = !session[sessionToken] && !allowRequestsWithoutSession @@ -75,8 +76,10 @@ class ConnectionController { if (userSession != null && !isSessionTokenInvalid) { Meeting m = meetingService.getMeeting(userSession.meetingID) - User u = m.getUserById(userSession.internalUserId) - + User u + if(m) { + u = m.getUserById(userSession.internalUserId) + } Boolean cursorLocked = false Boolean annotationsLocked = false @@ -95,7 +98,7 @@ class ConnectionController { def builder = new JsonBuilder() builder { "response" "authorized" - "X-Hasura-Role" u && !u.hasLeft() ? "bbb_client" : "pre_join_bbb_client" + "X-Hasura-Role" m && u && !u.hasLeft() ? "bbb_client" : "not_joined_bbb_client" "X-Hasura-ModeratorInMeeting" u && u.isModerator() ? userSession.meetingID : "" "X-Hasura-PresenterInMeeting" u && u.isPresenter() ? userSession.meetingID : "" "X-Hasura-UserId" userSession.internalUserId @@ -112,10 +115,29 @@ class ConnectionController { } } } else { - throw new Exception("Invalid User Session") + UserSessionBasicData removedUserSession = meetingService.getRemovedUserSessionWithSessionToken(sessionToken) + if(removedUserSession) { + response.setStatus(200) + withFormat { + json { + def builder = new JsonBuilder() + builder { + "response" "authorized" + "X-Hasura-Role" "not_joined_bbb_client" + "X-Hasura-ModeratorInMeeting" "" + "X-Hasura-PresenterInMeeting" "" + "X-Hasura-UserId" removedUserSession.userId + "X-Hasura-MeetingId" removedUserSession.meetingId + } + render(contentType: "application/json", text: builder.toPrettyString()) + } + } + } else { + throw new Exception("Invalid User Session") + } } } catch (Exception e) { - log.error("Error while authenticating graphql connection.\n" + e.getMessage()) + log.debug("Error while authenticating graphql connection: " + e.getMessage()) response.setStatus(401) withFormat { json { @@ -133,7 +155,7 @@ class ConnectionController { try { def uri = request.getHeader("x-original-uri") def sessionToken = ParamsUtil.getSessionToken(uri) - UserSession userSession = meetingService.getUserSessionWithAuthToken(sessionToken) + UserSession userSession = meetingService.getUserSessionWithSessionToken(sessionToken) response.addHeader("Cache-Control", "no-cache") response.contentType = 'plain/text' From dac9b5b1f65e30fda4e4ee8f393b0b69765ccd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Mon, 5 Feb 2024 13:43:25 -0300 Subject: [PATCH 0322/1039] fix(polling): invisible quick poll dropdown --- .../imports/ui/components/presentation/component.jsx | 1 + bigbluebutton-html5/imports/ui/components/presentation/styles.js | 1 + 2 files changed, 2 insertions(+) diff --git a/bigbluebutton-html5/imports/ui/components/presentation/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/component.jsx index c671e5b61f..22a005f6c8 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/component.jsx @@ -840,6 +840,7 @@ class Presentation extends PureComponent { height: svgDimensions.height < 0 ? 0 : svgDimensions.height, textAlign: 'center', display: !presentationIsOpen ? 'none' : 'block', + zIndex: 1, }} id="presentationInnerWrapper" > diff --git a/bigbluebutton-html5/imports/ui/components/presentation/styles.js b/bigbluebutton-html5/imports/ui/components/presentation/styles.js index bc8778ca4c..bd91bfceaa 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/styles.js +++ b/bigbluebutton-html5/imports/ui/components/presentation/styles.js @@ -161,6 +161,7 @@ const PresentationToolbar = styled.div` order: 2; position: absolute; bottom: 0; + z-index: 2; `; const ToastSeparator = styled(ToastStyled.Separator)``; From 8ae1427f3c0e5efbfb16420778971ca96e4f7b43 Mon Sep 17 00:00:00 2001 From: Gustavo Trott Date: Tue, 6 Feb 2024 09:30:45 -0300 Subject: [PATCH 0323/1039] Graphql: Allow not-joined-users query ClientSettings --- .../tables/public_v_meeting_clientSettings.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_meeting_clientSettings.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_meeting_clientSettings.yaml index e09ec423e9..d1e095d77b 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_meeting_clientSettings.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_meeting_clientSettings.yaml @@ -15,3 +15,11 @@ select_permissions: meetingId: _eq: X-Hasura-MeetingId comment: "" + - role: not_joined_bbb_client + permission: + columns: + - clientSettingsJson + filter: + meetingId: + _eq: X-Hasura-MeetingId + comment: "" From a622ce6265ae31053b0bf6b36f35a19b8fae7f33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= Date: Tue, 6 Feb 2024 10:12:25 -0300 Subject: [PATCH 0324/1039] Labeless leave meeting applied and also tweaks --- .../actions-dropdown/component.jsx | 2 + .../leave-meeting-button/component.jsx | 204 ++++++++++++++++-- .../leave-meeting-button/container.jsx | 25 +++ .../nav-bar/leave-meeting-button/styles.js | 19 +- .../nav-bar/options-dropdown/component.jsx | 4 +- .../private/config/settings.yml | 2 +- bigbluebutton-html5/public/locales/en.json | 1 + 7 files changed, 229 insertions(+), 28 deletions(-) create mode 100644 bigbluebutton-html5/imports/ui/components/nav-bar/leave-meeting-button/container.jsx diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/actions-dropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/actions-dropdown/component.jsx index 67185ec318..30a4a18b95 100755 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/actions-dropdown/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/actions-dropdown/component.jsx @@ -191,6 +191,7 @@ class ActionsDropdown extends PureComponent { isCameraAsContentEnabled, isTimerFeatureEnabled, presentations, + isDirectLeaveButtonEnabled, } = this.props; const { pollBtnLabel, presentationLabel, takePresenter } = intlMessages; @@ -293,6 +294,7 @@ class ActionsDropdown extends PureComponent { key: 'layoutModal', onClick: () => this.setLayoutModalIsOpen(true), dataTest: 'manageLayoutBtn', + divider: !isDirectLeaveButtonEnabled, }); } diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/leave-meeting-button/component.jsx b/bigbluebutton-html5/imports/ui/components/nav-bar/leave-meeting-button/component.jsx index e42da4e9e2..10d62c8617 100644 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/leave-meeting-button/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/leave-meeting-button/component.jsx @@ -1,34 +1,196 @@ -import React from 'react'; +import React, { PureComponent } from 'react'; +import { defineMessages, injectIntl } from 'react-intl'; import PropTypes from 'prop-types'; -import Styled from './styles'; +import EndMeetingConfirmationContainer from '/imports/ui/components/end-meeting-confirmation/container'; import { makeCall } from '/imports/ui/services/api'; +import BBBMenu from '/imports/ui/components/common/menu/component'; +import { colorDanger, colorWhite } from '/imports/ui/stylesheets/styled-components/palette'; +import Styled from './styles'; -// Set the logout code to 680 because it's not a real code and can be matched on the other side -const LOGOUT_CODE = '680'; +const intlMessages = defineMessages({ + leaveMeetingBtnLabel: { + id: 'app.navBar.leaveMeetingBtnLabel', + description: 'Leave meeting button label', + }, + leaveMeetingBtnDesc: { + id: 'app.navBar.leaveMeetingBtnDesc', + description: 'Describes the leave meeting button', + }, + leaveSessionLabel: { + id: 'app.navBar.optionsDropdown.leaveSessionLabel', + description: 'Leave session button label', + }, + leaveSessionDesc: { + id: 'app.navBar.settingsDropdown.leaveSessionDesc', + description: 'Describes leave session option', + }, + endMeetingLabel: { + id: 'app.navBar.optionsDropdown.endMeetingForAllLabel', + description: 'End meeting button label', + }, + endMeetingDesc: { + id: 'app.navBar.settingsDropdown.endMeetingDesc', + description: 'Describes settings option closing the current meeting', + }, +}); const propTypes = { - label: PropTypes.string.isRequired, + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + amIModerator: PropTypes.bool, + isBreakoutRoom: PropTypes.bool, + isMeteorConnected: PropTypes.bool.isRequired, + isDropdownOpen: PropTypes.bool, + isMobile: PropTypes.bool.isRequired, }; -const LeaveMeetingButton = ({ label }) => { - const leaveSession = () => { +const defaultProps = { + amIModerator: false, + isBreakoutRoom: false, + isDropdownOpen: false, +}; + +class LeaveMeetingButton extends PureComponent { + constructor(props) { + super(props); + + this.state = { + isEndMeetingConfirmationModalOpen: false, + }; + + // Set the logout code to 680 because it's not a real code and can be matched on the other side + this.LOGOUT_CODE = '680'; + + this.setEndMeetingConfirmationModalIsOpen = this + .setEndMeetingConfirmationModalIsOpen.bind(this); + this.leaveSession = this.leaveSession.bind(this); + } + + setEndMeetingConfirmationModalIsOpen(value) { + this.setState({ isEndMeetingConfirmationModalOpen: value }); + } + + leaveSession() { makeCall('userLeftMeeting'); // we don't check askForFeedbackOnLogout here, // it is checked in meeting-ended component - Session.set('codeError', LOGOUT_CODE); - }; + Session.set('codeError', this.LOGOUT_CODE); + } - return ( - leaveSession()} - icon="logout" - /> - ); -}; + renderMenuItems() { + const { + intl, amIModerator, isBreakoutRoom, isMeteorConnected, + } = this.props; + + const allowedToEndMeeting = amIModerator && !isBreakoutRoom; + + const { allowLogout: allowLogoutSetting } = Meteor.settings.public.app; + + this.menuItems = []; + + if (allowLogoutSetting && isMeteorConnected) { + this.menuItems.push( + { + key: 'list-item-logout', + dataTest: 'logout-button', + icon: 'logout', + label: intl.formatMessage(intlMessages.leaveSessionLabel), + description: intl.formatMessage(intlMessages.leaveSessionDesc), + onClick: () => this.leaveSession(), + }, + ); + } + + if (allowedToEndMeeting && isMeteorConnected) { + const customStyles = { background: colorDanger, color: colorWhite }; + + this.menuItems.push( + { + key: 'list-item-end-meeting', + icon: 'close', + label: intl.formatMessage(intlMessages.endMeetingLabel), + description: intl.formatMessage(intlMessages.endMeetingDesc), + customStyles, + onClick: () => this.setEndMeetingConfirmationModalIsOpen(true), + }, + ); + } + + return this.menuItems; + } + + // eslint-disable-next-line class-methods-use-this + renderModal( + isOpen, + setIsOpen, + priority, + Component, + otherOptions, + ) { + return isOpen ? ( + setIsOpen(false), + priority, + setIsOpen, + isOpen, + }} + /> + ) : null; + } + + render() { + const { + intl, + isDropdownOpen, + isMobile, + isRTL, + } = this.props; + + const { isEndMeetingConfirmationModalOpen } = this.state; + + const customStyles = { top: '1rem' }; + + return ( + <> + null} + /> + )} + actions={this.renderMenuItems()} + opts={{ + id: 'app-leave-meeting-menu', + keepMounted: true, + transitionDuration: 0, + elevation: 3, + getcontentanchorel: null, + fullwidth: 'true', + anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'left' : 'right' }, + transformorigin: { vertical: 'top', horizontal: isRTL ? 'left' : 'right' }, + }} + /> + {this.renderModal(isEndMeetingConfirmationModalOpen, + this.setEndMeetingConfirmationModalIsOpen, + 'low', EndMeetingConfirmationContainer)} + + ); + } +} LeaveMeetingButton.propTypes = propTypes; - -export default LeaveMeetingButton; +LeaveMeetingButton.defaultProps = defaultProps; +export default injectIntl(LeaveMeetingButton); diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/leave-meeting-button/container.jsx b/bigbluebutton-html5/imports/ui/components/nav-bar/leave-meeting-button/container.jsx new file mode 100644 index 0000000000..15a446cf2f --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/leave-meeting-button/container.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import deviceInfo from '/imports/utils/deviceInfo'; +import LeaveMeetingButton from './component'; +import { meetingIsBreakout } from '/imports/ui/components/app/service'; +import { layoutSelectInput, layoutSelect } from '../../layout/context'; +import { SMALL_VIEWPORT_BREAKPOINT } from '../../layout/enums'; + +const LeaveMeetingButtonContainer = (props) => { + const { width: browserWidth } = layoutSelectInput((i) => i.browser); + const isMobile = browserWidth <= SMALL_VIEWPORT_BREAKPOINT; + const isRTL = layoutSelect((i) => i.isRTL); + + return ( + + ); +}; + +export default withTracker((props) => ({ + amIModerator: props.amIModerator, + isMobile: deviceInfo.isMobile, + isMeteorConnected: Meteor.status().connected, + isBreakoutRoom: meetingIsBreakout(), + isDropdownOpen: Session.get('dropdownOpen'), +}))(LeaveMeetingButtonContainer); diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/leave-meeting-button/styles.js b/bigbluebutton-html5/imports/ui/components/nav-bar/leave-meeting-button/styles.js index b9017db50a..257adab113 100644 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/leave-meeting-button/styles.js +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/leave-meeting-button/styles.js @@ -1,11 +1,22 @@ import styled from 'styled-components'; +import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; import Button from '/imports/ui/components/common/button/component'; const LeaveButton = styled(Button)` - border-radius: 1.1rem; - font-size: 1rem; - line-height: 1.1rem; - font-weight: 400; + ${({ state }) => state === 'open' && ` + @media ${smallOnly} { + display: none; + } + `} + ${({ state }) => state === 'closed' && ` + margin-left: 1.0rem; + margin-right: 0.5rem; + border-radius: 1.1rem; + font-size: 1rem; + line-height: 1.1rem; + font-weight: 400; + z-index: 3; + `} `; export default { diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/options-dropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/nav-bar/options-dropdown/component.jsx index 64076e2c2b..1d51d7f8a1 100644 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/options-dropdown/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/options-dropdown/component.jsx @@ -81,7 +81,7 @@ const intlMessages = defineMessages({ description: 'Describes help option', }, endMeetingLabel: { - id: 'app.navBar.optionsDropdown.endMeetingLabel', + id: 'app.navBar.optionsDropdown.endMeetingForAllLabel', description: 'End meeting options label', }, endMeetingDesc: { @@ -372,7 +372,7 @@ class OptionsDropdown extends PureComponent { this.menuItems.push( { key: 'list-item-end-meeting', - icon: 'application', + icon: 'close', label: intl.formatMessage(intlMessages.endMeetingLabel), description: intl.formatMessage(intlMessages.endMeetingDesc), customStyles, diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index 3c2240bd07..80b0cca3de 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -58,7 +58,7 @@ public: askForFeedbackOnLogout: false # the default logoutUrl matches window.location.origin i.e. bigbluebutton.org for demo.bigbluebutton.org # in some cases we want only custom logoutUrl to be used when provided on meeting create. Default value: true - askForConfirmationOnLeave: true + askForConfirmationOnLeave: false wakeLock: enabled: true allowDefaultLogoutUrl: true diff --git a/bigbluebutton-html5/public/locales/en.json b/bigbluebutton-html5/public/locales/en.json index dbf310ea70..600052334c 100755 --- a/bigbluebutton-html5/public/locales/en.json +++ b/bigbluebutton-html5/public/locales/en.json @@ -457,6 +457,7 @@ "app.navBar.optionsDropdown.helpDesc": "Links user to video tutorials (opens new tab)", "app.navBar.optionsDropdown.endMeetingDesc": "Terminates the current meeting", "app.navBar.optionsDropdown.endMeetingLabel": "End meeting", + "app.navBar.optionsDropdown.endMeetingForAllLabel": "End meeting for all", "app.navBar.userListToggleBtnLabel": "User list toggle", "app.navBar.toggleUserList.ariaLabel": "Users and messages toggle", "app.navBar.toggleUserList.newMessages": "with new message notification", From eeeaca7fc55fb14ccab6ad6b17f17aec4932e0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= Date: Tue, 6 Feb 2024 10:45:23 -0300 Subject: [PATCH 0325/1039] Requested changes --- bigbluebutton-html5/imports/ui/components/chat/service.js | 4 ++-- bigbluebutton-html5/public/locales/en.json | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/chat/service.js b/bigbluebutton-html5/imports/ui/components/chat/service.js index 609607b9d7..66649d2658 100755 --- a/bigbluebutton-html5/imports/ui/components/chat/service.js +++ b/bigbluebutton-html5/imports/ui/components/chat/service.js @@ -53,8 +53,8 @@ const intlMessages = defineMessages({ description: 'Label to identify original presentation exported', }, withWhiteboardAnnotations: { - id: 'app.presentationUploader.export.withWhiteboardAnnotations', - description: 'Label to identify in current state presentation exported', + id: 'app.presentationUploader.withWhiteboardAnnotations', + description: 'Used for indicating that presentation has annotations', }, }); diff --git a/bigbluebutton-html5/public/locales/en.json b/bigbluebutton-html5/public/locales/en.json index 38711753f9..e8671d153e 100755 --- a/bigbluebutton-html5/public/locales/en.json +++ b/bigbluebutton-html5/public/locales/en.json @@ -300,13 +300,12 @@ "app.presentationUploader.exportCurrentStatePresentation": "Send out a download link for the presentation including whiteboard annotations", "app.presentationUploader.enableOriginalPresentationDownload": "Enable download of the presentation ({0})", "app.presentationUploader.disableOriginalPresentationDownload": "Disable download of the original presentation ({0})", - "app.presentationUploader.withWhiteboardAnnotations": " (with whiteboard annotations)", + "app.presentationUploader.withWhiteboardAnnotations": "(with whiteboard annotations)", "app.presentationUploader.dropdownExportOptions": "Export options", "app.presentationUploader.export.linkAvailable": "Link for downloading {0} available on the public chat.", "app.presentationUploader.export.downloadButtonAvailable": "Download button for presentation {0} is available.", "app.presentationUploader.export.notAccessibleWarning": "may not be accessibility compliant", "app.presentationUploader.export.originalLabel": "Original", - "app.presentationUploader.export.withWhiteboardAnnotations": "with whiteboard annotations", "app.presentationUploader.currentPresentationLabel": "Current presentation", "app.presentationUploder.extraHint": "IMPORTANT: each file may not exceed {0} MB and {1} pages.", "app.presentationUploder.uploadLabel": "Upload", From 37cc9e4755ed2feca175b5bab2d3d4ea3222b149 Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Tue, 6 Feb 2024 09:06:25 -0500 Subject: [PATCH 0326/1039] docs: Close code block in customize md (port of 19588) --- docs/docs/administration/customize.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/administration/customize.md b/docs/docs/administration/customize.md index 0cd2fd98dd..06fb80ea41 100644 --- a/docs/docs/administration/customize.md +++ b/docs/docs/administration/customize.md @@ -71,6 +71,7 @@ And adjust it to the desired number of days. If you would instead like to comple ```bash remove_raw_of_published_recordings +``` #### Delete recordings older than N days From b46464e94ae05319977e8458df9cadb77a850f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Tue, 6 Feb 2024 11:28:29 -0300 Subject: [PATCH 0327/1039] fix(whiteboard): remove absent font loading --- bigbluebutton-html5/client/main.html | 1 - 1 file changed, 1 deletion(-) diff --git a/bigbluebutton-html5/client/main.html b/bigbluebutton-html5/client/main.html index 91aacbf901..33520fb7ad 100755 --- a/bigbluebutton-html5/client/main.html +++ b/bigbluebutton-html5/client/main.html @@ -135,7 +135,6 @@ with BigBlueButton; if not, see . - - `; - const contentWithStyle = [contentSplit[0], contentStyle, contentSplit[1]].join(''); - return ( - - - - ); -}; - -export default PadContent; diff --git a/bigbluebutton-html5/imports/ui/components/pads/content/container.jsx b/bigbluebutton-html5/imports/ui/components/pads/content/container.jsx deleted file mode 100644 index c6b2087d1f..0000000000 --- a/bigbluebutton-html5/imports/ui/components/pads/content/container.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { withTracker } from 'meteor/react-meteor-data'; -import PadContent from './component'; -import Service from '/imports/ui/components/pads/service'; - -const Container = (props) => ; - -export default withTracker(({ externalId }) => { - const content = Service.getPadContent(externalId); - - return { - content, - }; -})(Container); diff --git a/bigbluebutton-html5/imports/ui/components/pads/content/styles.js b/bigbluebutton-html5/imports/ui/components/pads/content/styles.js deleted file mode 100644 index b1e1d5717a..0000000000 --- a/bigbluebutton-html5/imports/ui/components/pads/content/styles.js +++ /dev/null @@ -1,54 +0,0 @@ -import styled from 'styled-components'; -import { - colorGray, - colorGrayLightest -} from '/imports/ui/stylesheets/styled-components/palette'; - -const Wrapper = styled.div` - display: flex; - height: 100%; - position: relative; - width: 100%; -`; - -const contentText = ` -font-family: Verdana, Arial, Helvetica, sans-serif; -font-size: 15px; -color: ${colorGray}; -bottom: 0; -box-sizing: border-box; -display: block; -overflow-x: hidden; -overflow-wrap: break-word; -overflow-y: auto; -padding-top: 1rem; -position: absolute; -right: 0; -left:0; -top: 0; -white-space: normal; - - -[dir="ltr"] & { - padding-left: 1rem; - padding-right: .5rem; -} - -[dir="rtl"] & { - padding-left: .5rem; - padding-right: 1rem; -} -`; - -const Iframe = styled.iframe` - border-width: 0; - width: 100%; - border-top: 1px solid ${colorGrayLightest}; - border-bottom: 1px solid ${colorGrayLightest}; -`; - -export default { - Wrapper, - Iframe, - contentText, -}; diff --git a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/service.ts b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/service.ts index b58c3f837e..d228736a05 100644 --- a/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/service.ts +++ b/bigbluebutton-html5/imports/ui/components/pads/pads-graphql/service.ts @@ -1,5 +1,4 @@ import { makeCall } from '/imports/ui/services/api'; -import { PadsUpdates } from '/imports/api/pads'; import Auth from '/imports/ui/services/auth'; import Settings from '/imports/ui/services/settings'; @@ -34,22 +33,8 @@ const buildPadURL = (padId: string, sessionIds: Array) => { return url; }; -const getPadTail = (externalId: string) => { - const updates = PadsUpdates.findOne( - { - meetingId: Auth.meetingID, - externalId, - }, { fields: { tail: 1 } }, - ); - - if (updates && updates.tail) return updates.tail; - - return ''; -}; - export default { createGroup, buildPadURL, - getPadTail, getParams, }; diff --git a/bigbluebutton-html5/imports/ui/components/pads/service.js b/bigbluebutton-html5/imports/ui/components/pads/service.js index c20d13657d..3ebbcb1677 100644 --- a/bigbluebutton-html5/imports/ui/components/pads/service.js +++ b/bigbluebutton-html5/imports/ui/components/pads/service.js @@ -1,5 +1,5 @@ import { throttle } from 'radash'; -import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads'; +import Pads from '/imports/api/pads'; import { makeCall } from '/imports/ui/services/api'; import Auth from '/imports/ui/services/auth'; import Settings from '/imports/ui/services/settings'; @@ -31,85 +31,10 @@ const getPadId = (externalId) => makeCall('getPadId', externalId); const createGroup = (externalId, model, name) => makeCall('createGroup', externalId, model, name); -const hasPad = (externalId) => { - const pad = Pads.findOne( - { - meetingId: Auth.meetingID, - externalId, - }, - ); - - return pad !== undefined; -}; - const createSession = (externalId) => makeCall('createSession', externalId); const throttledCreateSession = throttle({ interval: THROTTLE_TIMEOUT }, createSession); -const buildPadURL = (padId) => { - if (padId) { - const padsSessions = PadsSessions.findOne({}); - if (padsSessions && padsSessions.sessions) { - const params = getParams(); - const sessionIds = padsSessions.sessions.map((session) => Object.values(session)).join(','); - const url = Auth.authenticateURL(`${PADS_CONFIG.url}/auth_session?padName=${padId}&sessionID=${sessionIds}&${params}`); - return url; - } - } - - return null; -}; - -const getRev = (externalId) => { - const updates = PadsUpdates.findOne( - { - meetingId: Auth.meetingID, - externalId, - }, { fields: { rev: 1 } }, - ); - - return updates ? updates.rev : 0; -}; - -const getPadTail = (externalId) => { - const updates = PadsUpdates.findOne( - { - meetingId: Auth.meetingID, - externalId, - }, { fields: { tail: 1 } }, - ); - - if (updates && updates.tail) return updates.tail; - - return ''; -}; - -const getPadContent = (externalId) => { - const updates = PadsUpdates.findOne( - { - meetingId: Auth.meetingID, - externalId, - }, { fields: { content: 1 } }, - ); - - if (updates && updates.content) return updates.content; - - return ''; -}; - -const getPinnedPad = () => { - const pad = Pads.findOne({ - meetingId: Auth.meetingID, - pinned: true, - }, { - fields: { - externalId: 1, - }, - }); - - return pad; -}; - const pinPad = (externalId, pinned, stopWatching) => { if (pinned) { // Stop external video sharing if it's running. @@ -127,13 +52,7 @@ const throttledPinPad = throttle({ interval: 1000 }, pinPad); export default { getPadId, createGroup, - hasPad, createSession: (externalId) => throttledCreateSession(externalId), - buildPadURL, - getRev, - getPadTail, - getPadContent, getParams, - getPinnedPad, pinPad: throttledPinPad, }; diff --git a/bigbluebutton-html5/imports/ui/components/pads/sessions/service.js b/bigbluebutton-html5/imports/ui/components/pads/sessions/service.js index bf4c5d812e..b0fad030fa 100644 --- a/bigbluebutton-html5/imports/ui/components/pads/sessions/service.js +++ b/bigbluebutton-html5/imports/ui/components/pads/sessions/service.js @@ -1,29 +1,8 @@ -import { PadsSessions } from '/imports/api/pads'; - const COOKIE_CONFIG = window.meetingClientSettings.public.pads.cookie; const PATH = COOKIE_CONFIG.path; const SAME_SITE = COOKIE_CONFIG.sameSite; const SECURE = COOKIE_CONFIG.secure; -const getSessions = () => { - const padsSessions = PadsSessions.findOne({}); - - if (padsSessions) { - return padsSessions.sessions; - } - - return []; -}; - -const hasSession = (externalId) => { - const padsSessions = PadsSessions.findOne({}); - - if (padsSessions && padsSessions.sessions) { - return padsSessions.sessions.some(session => session[externalId]); - } - - return false; -}; const setCookie = (sessions) => { const sessionIds = sessions.map(session => Object.values(session)).join(','); @@ -31,7 +10,5 @@ const setCookie = (sessions) => { }; export default { - getSessions, - hasSession, setCookie, }; diff --git a/bigbluebutton-html5/imports/ui/components/screenshare/container.jsx b/bigbluebutton-html5/imports/ui/components/screenshare/container.jsx index 499443b04d..34f4d4861b 100755 --- a/bigbluebutton-html5/imports/ui/components/screenshare/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/screenshare/container.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { withTracker } from 'meteor/react-meteor-data'; -import { useMutation } from '@apollo/client'; +import { useMutation, useSubscription } from '@apollo/client'; import { getSharingContentType, getBroadcastContentType, @@ -17,6 +17,9 @@ import AudioService from '/imports/ui/components/audio/service'; import MediaService from '/imports/ui/components/media/service'; import { defineMessages } from 'react-intl'; import { EXTERNAL_VIDEO_STOP } from '../external-video-player/mutations'; +import { PINNED_PAD_SUBSCRIPTION } from '../notes/notes-graphql/queries'; + +const NOTES_CONFIG = window.meetingClientSettings.public.notes; const screenshareIntlMessages = defineMessages({ // SCREENSHARE @@ -98,6 +101,10 @@ const ScreenshareContainer = (props) => { const fullscreenContext = (element === fullscreenElementId); const [stopExternalVideoShare] = useMutation(EXTERNAL_VIDEO_STOP); + const { data: pinnedPadData } = useSubscription(PINNED_PAD_SUBSCRIPTION); + const isSharedNotesPinned = !!pinnedPadData + && pinnedPadData.sharedNotes[0]?.sharedNotesExtId === NOTES_CONFIG.id; + const { isPresenter } = props; const info = { @@ -132,6 +139,7 @@ const ScreenshareContainer = (props) => { ...screenShare, fullscreenContext, fullscreenElementId, + isSharedNotesPinned, stopExternalVideoShare, ...selectedInfo, } diff --git a/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx b/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx index dd5e3f036f..ddd2327ac1 100755 --- a/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx @@ -25,9 +25,9 @@ const SUBSCRIPTIONS = [ // 'voice-call-states', // 'breakouts', // 'breakouts-history', - 'pads', - 'pads-sessions', - 'pads-updates', + // 'pads', + // 'pads-sessions', + // 'pads-updates', // 'notifications', 'layout-meetings', 'user-reaction', diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-notes/container.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-notes/container.jsx index 1da5883e5e..c81adff7df 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-notes/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-notes/container.jsx @@ -2,15 +2,22 @@ import React from 'react'; import { withTracker } from 'meteor/react-meteor-data'; import NotesService from '/imports/ui/components/notes/service'; import lockContextContainer from '/imports/ui/components/lock-viewers/context/container'; +import { useSubscription } from '@apollo/client'; import UserNotes from './component'; import { layoutSelectInput, layoutDispatch } from '../../../layout/context'; import UserNotesContainerGraphql from '../../user-list-graphql/user-list-content/user-notes/component'; +import { PINNED_PAD_SUBSCRIPTION } from '../../../notes/notes-graphql/queries'; + +const NOTES_CONFIG = window.meetingClientSettings.public.notes; const UserNotesContainer = (props) => { const sidebarContent = layoutSelectInput((i) => i.sidebarContent); const { sidebarContentPanel } = sidebarContent; const layoutContextDispatch = layoutDispatch(); - return ; + const { data: pinnedPadData } = useSubscription(PINNED_PAD_SUBSCRIPTION); + const isPinned = !!pinnedPadData + && pinnedPadData.sharedNotes[0]?.sharedNotesExtId === NOTES_CONFIG.id; + return ; }; lockContextContainer(withTracker(({ userLocks }) => { @@ -18,7 +25,6 @@ lockContextContainer(withTracker(({ userLocks }) => { return { unread: NotesService.hasUnreadNotes(), disableNotes: shouldDisableNotes, - isPinned: NotesService.isSharedNotesPinned(), }; })(UserNotesContainer)); From 8fd5a81034d14786c797327b467d22c38a87cd8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Wed, 24 Apr 2024 15:41:49 -0300 Subject: [PATCH 0839/1039] fix save locale --- .../imports/ui/components/settings/component.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bigbluebutton-html5/imports/ui/components/settings/component.jsx b/bigbluebutton-html5/imports/ui/components/settings/component.jsx index c242618f3d..c374548253 100644 --- a/bigbluebutton-html5/imports/ui/components/settings/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/settings/component.jsx @@ -280,7 +280,7 @@ class Settings extends Component { if (saved.application.locale !== current.application.locale) { const { language } = formatLocaleCode(saved.application.locale); - const { language: newLanguage } = formatLocaleCode(current.application.locale); + const newLanguage = current.application.locale; setUseCurrentLocale(newLanguage); document.body.classList.remove(`lang-${language}`); } From cc79bb5fb2475082022ed6b816d73291c776542b Mon Sep 17 00:00:00 2001 From: Tainan Felipe Date: Wed, 24 Apr 2024 15:45:28 -0300 Subject: [PATCH 0840/1039] Refactor: user reaction to graphql --- .../tables/public_v_user_current.yaml | 2 +- .../meetings/server/modifiers/addMeeting.js | 4 - .../server/modifiers/meetingHasEnded.js | 3 - .../imports/api/user-reaction/index.js | 14 ---- .../api/user-reaction/server/eventHandlers.js | 6 -- .../server/handlers/clearUsersReaction.js | 7 -- .../server/handlers/setUserReaction.js | 7 -- .../api/user-reaction/server/helpers.js | 44 ----------- .../imports/api/user-reaction/server/index.js | 2 - .../server/modifiers/addUserReaction.js | 34 --------- .../server/modifiers/clearReactions.js | 26 ------- .../api/user-reaction/server/publishers.js | 27 ------- .../reactions-button/container.jsx | 22 +++--- .../ui/components/emoji-rain/container.jsx | 32 ++++++-- .../ui/components/emoji-rain/queries.ts | 14 ++++ .../ui/components/subscriptions/component.jsx | 4 +- .../ui/components/user-list/service.js | 76 +------------------ .../ui/components/user-reaction/service.js | 34 --------- .../queries/currentUserSubscription.ts | 3 + .../private/config/settings.yml | 2 +- bigbluebutton-html5/server/main.js | 1 - 21 files changed, 57 insertions(+), 307 deletions(-) delete mode 100644 bigbluebutton-html5/imports/api/user-reaction/index.js delete mode 100644 bigbluebutton-html5/imports/api/user-reaction/server/eventHandlers.js delete mode 100644 bigbluebutton-html5/imports/api/user-reaction/server/handlers/clearUsersReaction.js delete mode 100644 bigbluebutton-html5/imports/api/user-reaction/server/handlers/setUserReaction.js delete mode 100644 bigbluebutton-html5/imports/api/user-reaction/server/helpers.js delete mode 100644 bigbluebutton-html5/imports/api/user-reaction/server/index.js delete mode 100644 bigbluebutton-html5/imports/api/user-reaction/server/modifiers/addUserReaction.js delete mode 100644 bigbluebutton-html5/imports/api/user-reaction/server/modifiers/clearReactions.js delete mode 100644 bigbluebutton-html5/imports/api/user-reaction/server/publishers.js create mode 100644 bigbluebutton-html5/imports/ui/components/emoji-rain/queries.ts diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_current.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_current.yaml index 3bdb2388f9..ddb7e86aee 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_current.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_user_current.yaml @@ -59,7 +59,7 @@ object_relationships: userId: userId insertion_order: null remote_table: - name: v_user_reaction + name: v_user_reaction_current schema: public - name: sharedNotesSession using: diff --git a/bigbluebutton-html5/imports/api/meetings/server/modifiers/addMeeting.js b/bigbluebutton-html5/imports/api/meetings/server/modifiers/addMeeting.js index 19062b4691..3afd0f31cc 100755 --- a/bigbluebutton-html5/imports/api/meetings/server/modifiers/addMeeting.js +++ b/bigbluebutton-html5/imports/api/meetings/server/modifiers/addMeeting.js @@ -11,7 +11,6 @@ import Logger from '/imports/startup/server/logger'; import { initPads } from '/imports/api/pads/server/helpers'; import createTimer from '/imports/api/timer/server/methods/createTimer'; import { addExternalVideoStreamer } from '/imports/api/external-videos/server/streamer'; -import addUserReactionsObserver from '/imports/api/user-reaction/server/helpers'; import { LAYOUT_TYPE } from '/imports/ui/components/layout/enums'; const addLayout = async (meetingId, layout) => { @@ -232,9 +231,6 @@ export default async function addMeeting(meeting) { if (newMeeting.meetingProp.disabledFeatures.indexOf('sharedNotes') === -1) { initPads(meetingId); } - if (newMeeting.meetingProp.disabledFeatures.indexOf('reactions') === -1) { - await addUserReactionsObserver(meetingId); - } } else if (numberAffected) { Logger.info(`Upserted meeting id=${meetingId}`); } diff --git a/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js b/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js index d1ed7921bf..0bce7f9896 100755 --- a/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js +++ b/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js @@ -13,8 +13,6 @@ import clearTimer from '/imports/api/timer/server/modifiers/clearTimer'; import clearMeetingTimeRemaining from '/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining'; import clearVideoStreams from '/imports/api/video-streams/server/modifiers/clearVideoStreams'; import clearAuthTokenValidation from '/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation'; -import clearReactions from '/imports/api/user-reaction/server/modifiers/clearReactions'; - import Metrics from '/imports/startup/server/metrics'; export default async function meetingHasEnded(meetingId) { @@ -34,7 +32,6 @@ export default async function meetingHasEnded(meetingId) { clearVideoStreams(meetingId), clearAuthTokenValidation(meetingId), clearScreenshare(meetingId), - clearReactions(meetingId), ]); await Metrics.removeMeeting(meetingId); return Logger.info(`Cleared Meetings with id ${meetingId}`); diff --git a/bigbluebutton-html5/imports/api/user-reaction/index.js b/bigbluebutton-html5/imports/api/user-reaction/index.js deleted file mode 100644 index eb5ffcbde2..0000000000 --- a/bigbluebutton-html5/imports/api/user-reaction/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -const expireSeconds = Meteor.settings.public.userReaction.expire; -const UserReaction = new Mongo.Collection('user-reaction'); - -if (Meteor.isServer) { - // TTL indexes are special single-field indexes to automatically remove documents - // from a collection after a certain amount of time. - // A single-field with only a date is necessary to this special single-field index, because - // compound indexes do not support TTL. - UserReaction._ensureIndex({ creationDate: 1 }, { expireAfterSeconds: expireSeconds }); -} - -export default UserReaction; diff --git a/bigbluebutton-html5/imports/api/user-reaction/server/eventHandlers.js b/bigbluebutton-html5/imports/api/user-reaction/server/eventHandlers.js deleted file mode 100644 index 31437b0d7d..0000000000 --- a/bigbluebutton-html5/imports/api/user-reaction/server/eventHandlers.js +++ /dev/null @@ -1,6 +0,0 @@ -import RedisPubSub from '/imports/startup/server/redis'; -import handleSetUserReaction from './handlers/setUserReaction'; -import handleClearUsersReaction from './handlers/clearUsersReaction'; - -RedisPubSub.on('UserReactionEmojiChangedEvtMsg', handleSetUserReaction); -RedisPubSub.on('ClearedAllUsersReactionEvtMsg', handleClearUsersReaction); diff --git a/bigbluebutton-html5/imports/api/user-reaction/server/handlers/clearUsersReaction.js b/bigbluebutton-html5/imports/api/user-reaction/server/handlers/clearUsersReaction.js deleted file mode 100644 index b9113912b0..0000000000 --- a/bigbluebutton-html5/imports/api/user-reaction/server/handlers/clearUsersReaction.js +++ /dev/null @@ -1,7 +0,0 @@ -import { check } from 'meteor/check'; -import clearReactions from '../modifiers/clearReactions'; - -export default function handleClearUsersReaction({ body }, meetingId) { - check(meetingId, String); - clearReactions(meetingId); -} diff --git a/bigbluebutton-html5/imports/api/user-reaction/server/handlers/setUserReaction.js b/bigbluebutton-html5/imports/api/user-reaction/server/handlers/setUserReaction.js deleted file mode 100644 index 4b8e286291..0000000000 --- a/bigbluebutton-html5/imports/api/user-reaction/server/handlers/setUserReaction.js +++ /dev/null @@ -1,7 +0,0 @@ -import addUserReaction from '../modifiers/addUserReaction'; - -export default function handleSetUserReaction({ body }, meetingId) { - const { userId, reactionEmoji } = body; - - addUserReaction(meetingId, userId, reactionEmoji); -} diff --git a/bigbluebutton-html5/imports/api/user-reaction/server/helpers.js b/bigbluebutton-html5/imports/api/user-reaction/server/helpers.js deleted file mode 100644 index 1efe976b01..0000000000 --- a/bigbluebutton-html5/imports/api/user-reaction/server/helpers.js +++ /dev/null @@ -1,44 +0,0 @@ -import RedisPubSub from '/imports/startup/server/redis'; -import UserReactions from '/imports/api/user-reaction'; -import Logger from '/imports/startup/server/logger'; - -const expireSeconds = Meteor.settings.public.userReaction.expire; -const expireMilliseconds = expireSeconds * 1000; - -const notifyExpiredReaction = (meetingId, userId) => { - try { - const REDIS_CONFIG = Meteor.settings.private.redis; - const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; - const EVENT_NAME = 'UserReactionTimeExpiredCmdMsg'; - const NODE_USER = 'nodeJSapp'; - const emoji = 'none'; - - check(meetingId, String); - - const payload = { - userId, - }; - - Logger.verbose('User emoji status updated due to expiration time', { - emoji, NODE_USER, meetingId, - }); - - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, NODE_USER, payload); - } catch (err) { - Logger.error(`Exception while invoking method resetUserReaction ${err.stack}`); - } -}; - -const addUserReactionsObserver = (meetingId) => { - const meetingUserReactions = UserReactions.find({ meetingId }); - return meetingUserReactions.observe({ - removed(document) { - const isExpirationTriggeredRemoval = (Date.now() - Date.parse(document.creationDate)) >= expireMilliseconds; - if (isExpirationTriggeredRemoval) { - notifyExpiredReaction(meetingId, document.userId); - } - }, - }); -}; - -export default addUserReactionsObserver; diff --git a/bigbluebutton-html5/imports/api/user-reaction/server/index.js b/bigbluebutton-html5/imports/api/user-reaction/server/index.js deleted file mode 100644 index f993f38e5b..0000000000 --- a/bigbluebutton-html5/imports/api/user-reaction/server/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import './eventHandlers'; -import './publishers'; diff --git a/bigbluebutton-html5/imports/api/user-reaction/server/modifiers/addUserReaction.js b/bigbluebutton-html5/imports/api/user-reaction/server/modifiers/addUserReaction.js deleted file mode 100644 index 53d46c37a6..0000000000 --- a/bigbluebutton-html5/imports/api/user-reaction/server/modifiers/addUserReaction.js +++ /dev/null @@ -1,34 +0,0 @@ -import UserReaction from '/imports/api/user-reaction'; -import Logger from '/imports/startup/server/logger'; -import { check } from 'meteor/check'; - -export default function addUserReaction(meetingId, userId, reaction) { - check(meetingId, String); - check(userId, String); - check(reaction, String); - - const selector = { - creationDate: new Date(), - meetingId, - userId, - }; - - const modifier = { - $set: { - meetingId, - userId, - reaction, - }, - }; - - try { - UserReaction.remove({ meetingId, userId }); - const { numberAffected } = UserReaction.upsert(selector, modifier); - - if (numberAffected) { - Logger.verbose(`Added user reaction meetingId=${meetingId} userId=${userId}`); - } - } catch (err) { - Logger.error(`Adding user reaction: ${err}`); - } -} diff --git a/bigbluebutton-html5/imports/api/user-reaction/server/modifiers/clearReactions.js b/bigbluebutton-html5/imports/api/user-reaction/server/modifiers/clearReactions.js deleted file mode 100644 index 4696b48bb5..0000000000 --- a/bigbluebutton-html5/imports/api/user-reaction/server/modifiers/clearReactions.js +++ /dev/null @@ -1,26 +0,0 @@ -import UserReaction from '/imports/api/user-reaction'; -import Logger from '/imports/startup/server/logger'; - -export default function clearReactions(meetingId) { - const selector = {}; - - if (meetingId) { - selector.meetingId = meetingId; - } - - try { - const numberAffected = UserReaction.remove(selector); - - if (numberAffected) { - if (meetingId) { - Logger.info(`Removed UserReaction (${meetingId})`); - } else { - Logger.info('Removed UserReaction (all)'); - } - } else { - Logger.warn('Removing UserReaction nonaffected'); - } - } catch (err) { - Logger.error(`Removing UserReaction: ${err}`); - } -} diff --git a/bigbluebutton-html5/imports/api/user-reaction/server/publishers.js b/bigbluebutton-html5/imports/api/user-reaction/server/publishers.js deleted file mode 100644 index 82658437c3..0000000000 --- a/bigbluebutton-html5/imports/api/user-reaction/server/publishers.js +++ /dev/null @@ -1,27 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; -import Logger from '/imports/startup/server/logger'; -import UserReaction from '/imports/api/user-reaction'; -import { extractCredentials } from '/imports/api/common/server/helpers'; - -function userReaction() { - if (!this.userId) { - return UserReaction.find({ meetingId: '' }); - } - - const { meetingId, requesterUserId } = extractCredentials(this.userId); - - check(meetingId, String); - check(requesterUserId, String); - - Logger.info(`Publishing user reaction for ${meetingId} ${requesterUserId}`); - - return UserReaction.find({ meetingId }); -} - -function publish(...args) { - const boundUserReaction = userReaction.bind(this); - return boundUserReaction(...args); -} - -Meteor.publish('user-reaction', publish); diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/reactions-button/container.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/reactions-button/container.jsx index 0cf7fca3b4..7745b09ce9 100644 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/reactions-button/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/reactions-button/container.jsx @@ -3,7 +3,6 @@ import { withTracker } from 'meteor/react-meteor-data'; import { layoutSelectInput, layoutDispatch } from '/imports/ui/components/layout/context'; import { injectIntl } from 'react-intl'; import ReactionsButton from './component'; -import UserReactionService from '/imports/ui/components/user-reaction/service'; import { SMALL_VIEWPORT_BREAKPOINT } from '/imports/ui/components/layout/enums'; import SettingsService from '/imports/ui/services/settings'; import Auth from '/imports/ui/services/auth'; @@ -20,7 +19,9 @@ const ReactionsButtonContainer = ({ ...props }) => { const { data: currentUserData } = useCurrentUser((user) => ({ emoji: user.emoji, raiseHand: user.raiseHand, + reaction: user.reaction, })); + const currentUser = { userId: Auth.userID, emoji: currentUserData?.emoji, @@ -29,18 +30,17 @@ const ReactionsButtonContainer = ({ ...props }) => { return ( ); }; -export default injectIntl(withTracker(() => { - const currentUserReaction = UserReactionService.getUserReaction(Auth.userID); - - return { - currentUserReaction: currentUserReaction.reaction, - autoCloseReactionsBar: SettingsService?.application?.autoCloseReactionsBar, - }; -})(ReactionsButtonContainer)); - +export default injectIntl(withTracker(() => ({ + autoCloseReactionsBar: SettingsService?.application?.autoCloseReactionsBar, +}))(ReactionsButtonContainer)); diff --git a/bigbluebutton-html5/imports/ui/components/emoji-rain/container.jsx b/bigbluebutton-html5/imports/ui/components/emoji-rain/container.jsx index d6f24ef798..040df7f0fd 100644 --- a/bigbluebutton-html5/imports/ui/components/emoji-rain/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/emoji-rain/container.jsx @@ -1,11 +1,27 @@ -import React from 'react'; -import { withTracker } from 'meteor/react-meteor-data'; +import React, { useRef } from 'react'; +import { useSubscription } from '@apollo/client'; import EmojiRain from './component'; -import UserReaction from '/imports/api/user-reaction'; -import Auth from '/imports/ui/services/auth'; +import { getEmojisToRain } from './queries'; -const EmojiRainContainer = (props) => ; +const EmojiRainContainer = () => { + const nowDate = useRef(new Date().toUTCString()); -export default withTracker(() => ({ - reactions: UserReaction.find({ meetingId: Auth.meetingID }).fetch(), -}))(EmojiRainContainer); + const { + data: emojisToRainData, + } = useSubscription(getEmojisToRain, { + variables: { + initialCursor: nowDate.current, + }, + }); + const emojisArray = emojisToRainData?.user_reaction_stream || []; + + const reactions = emojisArray.length === 0 ? [] + : emojisArray.map((reaction) => ({ + reaction: reaction.reactionEmoji, + creationDate: new Date(reaction.createdAt), + })); + + return ; +}; + +export default EmojiRainContainer; diff --git a/bigbluebutton-html5/imports/ui/components/emoji-rain/queries.ts b/bigbluebutton-html5/imports/ui/components/emoji-rain/queries.ts new file mode 100644 index 0000000000..04e4240ece --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/emoji-rain/queries.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const getEmojisToRain = gql` +subscription getEmojisToRain ($initialCursor: timestamptz) { + user_reaction_stream(batch_size: 10, cursor: {initial_value: {createdAt: $initialCursor}}) { + createdAt + reactionEmoji + } +} +`; + +export default { + getEmojisToRain, +}; diff --git a/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx b/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx index dd5e3f036f..4668f85a0c 100755 --- a/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx @@ -29,8 +29,8 @@ const SUBSCRIPTIONS = [ 'pads-sessions', 'pads-updates', // 'notifications', - 'layout-meetings', - 'user-reaction', + // 'layout-meetings', + // 'user-reaction', 'timer', ]; const { diff --git a/bigbluebutton-html5/imports/ui/components/user-list/service.js b/bigbluebutton-html5/imports/ui/components/user-list/service.js index c2c889a503..d4ef972a9a 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/service.js +++ b/bigbluebutton-html5/imports/ui/components/user-list/service.js @@ -3,15 +3,12 @@ import Users from '/imports/api/users'; import VoiceUsers from '/imports/api/voice-users'; import Breakouts from '/imports/api/breakouts'; import Meetings from '/imports/api/meetings'; -import UserReaction from '/imports/api/user-reaction'; import Auth from '/imports/ui/services/auth'; import Storage from '/imports/ui/services/storage/session'; import { EMOJI_STATUSES } from '/imports/utils/statuses'; import { makeCall } from '/imports/ui/services/api'; import KEY_CODES from '/imports/utils/keyCodes'; import AudioService from '/imports/ui/components/audio/service'; -import VideoService from '/imports/ui/components/video-provider/service'; -import UserReactionService from '/imports/ui/components/user-reaction/service'; import logger from '/imports/startup/client/logger'; import { Session } from 'meteor/session'; import Settings from '/imports/ui/services/settings'; @@ -24,7 +21,6 @@ const CHAT_CONFIG = window.meetingClientSettings.public.chat; const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id; const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id; const ROLE_MODERATOR = window.meetingClientSettings.public.user.role_moderator; -const ROLE_VIEWER = window.meetingClientSettings.public.user.role_viewer; const USER_STATUS_ENABLED = window.meetingClientSettings.public.userStatus.enabled; const DIAL_IN_CLIENT_TYPE = 'dial-in-user'; @@ -143,75 +139,6 @@ const isPublicChat = (chat) => ( chat.userId === PUBLIC_CHAT_ID ); -const userFindSorting = { - emojiTime: 1, - role: 1, - phoneUser: 1, - name: 1, - userId: 1, -}; - -const addIsSharingWebcam = (users) => { - const usersId = VideoService.getUsersIdFromVideoStreams(); - - return users.map((user) => { - const isSharingWebcam = usersId.includes(user.userId); - - return { - ...user, - isSharingWebcam, - }; - }); -}; - -const addUserReaction = (users) => { - const usersReactions = UserReaction.find({ - meetingId: Auth.meetingID, - }).fetch(); - - return users.map((user) => { - let reaction = ''; - const obj = usersReactions.find((us) => us.userId === user.userId); - if (obj !== undefined) { - ({ reaction } = obj); - } - - return { - ...user, - reaction, - }; - }); -}; - -const formatUsers = (contextUsers, videoUsers, whiteboardUsers, reactionUsers) => { - let users = contextUsers.filter((user) => user.loggedOut === false && user.left === false); - - const currentUser = Users.findOne({ userId: Auth.userID }, { fields: { role: 1, locked: 1 } }); - if (currentUser && currentUser.role === ROLE_VIEWER && currentUser.locked) { - const meeting = Meetings.findOne({ meetingId: Auth.meetingID }, - { fields: { 'lockSettings.hideUserList': 1 } }); - if (meeting && meeting.lockSettings && meeting.lockSettings.hideUserList) { - const moderatorOrCurrentUser = (u) => u.role === ROLE_MODERATOR || u.userId === Auth.userID; - users = users.filter(moderatorOrCurrentUser); - } - } - - return users.map((user) => { - const isSharingWebcam = videoUsers?.includes(user.userId); - const whiteboardAccess = whiteboardUsers?.includes(user.userId); - const reaction = reactionUsers?.includes(user.userId) - ? UserReactionService.getUserReaction(user.userId) - : { reaction: 'none', reactionTime: 0 }; - - return { - ...user, - isSharingWebcam, - whiteboardAccess, - ...reaction, - }; - }).sort(sortUsers); -}; - const getUserCount = () => Users.find({ meetingId: Auth.meetingID }).count(); const hasBreakoutRoom = () => Breakouts.find({ parentMeetingId: Auth.meetingID }, @@ -668,13 +595,12 @@ const UserLeftMeetingAlert = (obj) => { obj.icon, ); } -} +}; export default { sortUsersByName, sortUsers, toggleVoice, - formatUsers, getActiveChats, getAvailableActions, curatedVoiceUser, diff --git a/bigbluebutton-html5/imports/ui/components/user-reaction/service.js b/bigbluebutton-html5/imports/ui/components/user-reaction/service.js index 275aba23ef..b4aee779ac 100644 --- a/bigbluebutton-html5/imports/ui/components/user-reaction/service.js +++ b/bigbluebutton-html5/imports/ui/components/user-reaction/service.js @@ -1,5 +1,3 @@ -import UserReaction from '/imports/api/user-reaction'; -import Auth from '/imports/ui/services/auth'; import getFromUserSettings from '/imports/ui/services/users-settings'; import { isReactionsEnabled } from '/imports/ui/services/features/index'; @@ -7,38 +5,6 @@ const ENABLED = window.meetingClientSettings.public.userReaction.enabled; const isEnabled = () => isReactionsEnabled() && getFromUserSettings('enable-user-reaction', ENABLED); -const getUsersIdFromUserReaction = () => UserReaction.find( - { meetingId: Auth.meetingID }, - { fields: { userId: 1 } }, -).fetch().map((user) => user.userId); - -const getUserReaction = (userId) => { - const reaction = UserReaction.findOne( - { - meetingId: Auth.meetingID, - userId, - }, - { - fields: - { - reaction: 1, - creationDate: 1, - }, - }, - ); - - if (!reaction) { - return { - reaction: 'none', - reactionTime: 0, - }; - } - - return { reaction: reaction.reaction, reactionTime: reaction.creationDate.getTime() }; -}; - export default { - getUserReaction, - getUsersIdFromUserReaction, isEnabled, }; diff --git a/bigbluebutton-html5/imports/ui/core/graphql/queries/currentUserSubscription.ts b/bigbluebutton-html5/imports/ui/core/graphql/queries/currentUserSubscription.ts index f88c885448..4998e6f44b 100644 --- a/bigbluebutton-html5/imports/ui/core/graphql/queries/currentUserSubscription.ts +++ b/bigbluebutton-html5/imports/ui/core/graphql/queries/currentUserSubscription.ts @@ -48,6 +48,9 @@ subscription userCurrentSubscription { parameter value } + reaction { + reactionEmoji + } breakoutRooms { currentRoomJoined assignedAt diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index 0dc7ac1131..e69df316b4 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -155,7 +155,7 @@ public: enabled: true emojiRain: # If true, new reactions will be activated - enabled: false + enabled: true # Can set the throttle, the number of emojis that will be displayed and their size intervalEmojis: 2000 numberOfEmojis: 5 diff --git a/bigbluebutton-html5/server/main.js b/bigbluebutton-html5/server/main.js index 0c7c72e68e..6e82702671 100755 --- a/bigbluebutton-html5/server/main.js +++ b/bigbluebutton-html5/server/main.js @@ -11,7 +11,6 @@ import '/imports/api/video-streams/server'; import '/imports/api/connection-status/server'; import '/imports/api/timer/server'; import '/imports/api/pads/server'; -import '/imports/api/user-reaction/server'; // Commons import '/imports/api/log-client/server'; From 5f0ab8f800584dc90163a94bb59f299ba63a294c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Souza?= Date: Wed, 24 Apr 2024 17:06:53 -0300 Subject: [PATCH 0841/1039] move muteAway and fix issue with joining listen only when away is active --- .../reactions-button/component.jsx | 33 ++----------------- .../buttons/muteToggle.tsx | 4 +++ .../input-stream-live-selector/service.ts | 29 ++++++++++++++++ .../audio/audio-modal/component.jsx | 6 ++++ 4 files changed, 41 insertions(+), 31 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/reactions-button/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/reactions-button/component.jsx index 228256efbf..c637e81c3f 100644 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/reactions-button/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/reactions-button/component.jsx @@ -10,12 +10,9 @@ import { SET_RAISE_HAND, SET_REACTION_EMOJI } from '/imports/ui/core/graphql/mut import { SET_AWAY } from '/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/mutations'; import { useMutation } from '@apollo/client'; import Toggle from '/imports/ui/components/common/switch/component'; -import VideoService from '/imports/ui/components/video-provider/service'; import useToggleVoice from '/imports/ui/components/audio/audio-graphql/hooks/useToggleVoice'; import { - getSpeakerLevel, - setSpeakerLevel, - toggleMuteMicrophone, + muteAway, } from '/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service'; import Styled from './styles'; @@ -45,8 +42,6 @@ const ReactionsButton = (props) => { const [showEmojiPicker, setShowEmojiPicker] = useState(false); const voiceToggle = useToggleVoice(); - const prevMutedRef = React.useRef(false); - const prevSpeakerLevel = React.useRef(0); const intlMessages = defineMessages({ reactionsLabel: { @@ -92,32 +87,8 @@ const ReactionsButton = (props) => { }); }; - const muteAway = () => { - const prevAwayMuted = prevMutedRef.current; - const prevSpeakerLevelValue = prevSpeakerLevel.current; - - // mute/unmute microphone - if (muted === away && muted === prevAwayMuted) { - toggleMuteMicrophone(muted, voiceToggle); - prevMutedRef.current = !muted; - } else if (!away && !muted && prevAwayMuted) { - toggleMuteMicrophone(muted, voiceToggle); - } - - // mute/unmute speaker - if (away) { - setSpeakerLevel(prevSpeakerLevelValue); - } else { - prevSpeakerLevel.current = getSpeakerLevel(); - setSpeakerLevel(0); - } - - // enable/disable video - VideoService.setTrackEnabled(away); - }; - const handleToggleAFK = () => { - muteAway(); + muteAway(muted, away, voiceToggle); setAway({ variables: { away: !away, diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/muteToggle.tsx b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/muteToggle.tsx index 2914229cc5..7d4bb1a884 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/muteToggle.tsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/buttons/muteToggle.tsx @@ -7,6 +7,9 @@ import Settings from '/imports/ui/services/settings'; import useToggleVoice from '../../../hooks/useToggleVoice'; import { SET_AWAY } from '/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/mutations'; import VideoService from '/imports/ui/components/video-provider/service'; +import { + muteAway, +} from '/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - temporary while settings are still in .js @@ -56,6 +59,7 @@ export const Mutetoggle: React.FC = ({ e.stopPropagation(); if (muted) { + muteAway(muted, true, toggleVoice); VideoService.setTrackEnabled(true); setAway({ variables: { diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service.ts index 93efc6f971..7b32adc540 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service.ts +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service.ts @@ -4,6 +4,7 @@ import getFromUserSettings from '/imports/ui/services/users-settings'; import Storage from '/imports/ui/services/storage/session'; import logger from '/imports/startup/client/logger'; import AudioManager from '/imports/ui/services/audio-manager'; +import VideoService from '/imports/ui/components/video-provider/service'; const MUTED_KEY = 'muted'; // @ts-ignore - temporary, while meteor exists in the project @@ -94,6 +95,34 @@ export const setSpeakerLevel = (level: number) => { } }; +export const muteAway = ( + muted: boolean, + away: boolean, + voiceToggle: (userId?: string | null, muted?: boolean | null) => void, +) => { + const prevAwayMuted = Storage.getItem('prevAwayMuted') || false; + const prevSpeakerLevelValue = Storage.getItem('prevSpeakerLevel') || 1; + + // mute/unmute microphone + if (muted === away && muted === Boolean(prevAwayMuted)) { + toggleMuteMicrophone(muted, voiceToggle); + Storage.setItem('prevAwayMuted', !muted); + } else if (!away && !muted && Boolean(prevAwayMuted)) { + toggleMuteMicrophone(muted, voiceToggle); + } + + // mute/unmute speaker + if (away) { + setSpeakerLevel(Number(prevSpeakerLevelValue)); + } else { + Storage.setItem('prevSpeakerLevel', getSpeakerLevel()); + setSpeakerLevel(0); + } + + // enable/disable video + VideoService.setTrackEnabled(away); +}; + export default { handleLeaveAudio, toggleMuteMicrophone, diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/component.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/component.jsx index 9792a7c7c1..56968244f8 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/component.jsx @@ -17,6 +17,10 @@ import usePreviousValue from '/imports/ui/hooks/usePreviousValue'; import { SET_AWAY } from '/imports/ui/components/user-list/user-list-content/user-participants/user-list-participants/user-actions/mutations'; import VideoService from '/imports/ui/components/video-provider/service'; import AudioCaptionsSelectContainer from '../audio-graphql/audio-captions/captions/component'; +import useToggleVoice from '/imports/ui/components/audio/audio-graphql/hooks/useToggleVoice'; +import { + muteAway, +} from '/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service'; const propTypes = { intl: PropTypes.shape({ @@ -143,6 +147,7 @@ const AudioModal = (props) => { const [errCode, setErrCode] = useState(null); const [autoplayChecked, setAutoplayChecked] = useState(false); const [setAway] = useMutation(SET_AWAY); + const voiceToggle = useToggleVoice(); const { forceListenOnlyAttendee, @@ -260,6 +265,7 @@ const AudioModal = (props) => { }; const disableAwayMode = () => { + muteAway(false, true, voiceToggle); setAway({ variables: { away: false, From 1f8a95a3ff9c67518cd6abbd3ffaa83d85290ce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Fri, 19 Apr 2024 17:34:43 -0300 Subject: [PATCH 0842/1039] Remove video-streams subscription --- bigbluebutton-html5/client/main.tsx | 2 + .../imports/startup/client/base.jsx | 17 +- .../ui/components/actions-bar/component.jsx | 2 +- .../imports/ui/components/app/component.jsx | 2 + .../breakout-join-confirmation/component.jsx | 8 +- .../breakout-join-confirmation/container.jsx | 6 + .../ui/components/breakout-room/component.jsx | 8 +- .../ui/components/breakout-room/container.jsx | 3 + .../connection-manager/component.tsx | 2 +- .../connection-status/modal/component.jsx | 5 +- .../connection-status/modal/container.jsx | 3 + .../components/connection-status/service.js | 10 +- .../submenus/application/component.jsx | 2 +- .../ui/components/subscriptions/component.jsx | 2 +- .../ui/components/user-list/service.js | 2 +- .../ui/components/video-preview/component.jsx | 2 +- .../ui/components/video-preview/container.jsx | 40 +- .../ui/components/video-preview/service.js | 2 +- .../components/video-provider/container.jsx | 5 +- .../video-provider-graphql/adapter.tsx | 38 + .../video-provider-graphql/component.tsx | 1291 +++++++++++++++++ .../video-provider-graphql/container.tsx | 122 ++ .../video-provider-graphql/hooks/index.ts | 615 ++++++++ .../many-users-notify/component.jsx | 101 ++ .../many-users-notify/container.jsx | 52 + .../many-users-notify/styles.js | 35 + .../video-provider-graphql/mutations.ts | 22 + .../video-provider-graphql/queries.ts | 116 ++ .../video-provider-graphql/service.ts | 1118 ++++++++++++++ .../video-provider-graphql/state.ts | 77 + .../video-provider-graphql/stream-sorting.ts | 140 ++ .../video-button/component.jsx | 274 ++++ .../video-button/container.jsx | 67 + .../video-button/styles.js | 9 + .../video-list/component.tsx | 400 +++++ .../video-list/container.tsx | 41 + .../video-list/styles.ts | 118 ++ .../video-list/video-list-item/component.tsx | 315 ++++ .../video-list/video-list-item/container.tsx | 64 + .../drag-and-drop/component.jsx | 173 +++ .../video-list-item/pin-area/component.jsx | 63 + .../video-list-item/pin-area/styles.js | 43 + .../video-list/video-list-item/styles.ts | 196 +++ .../user-actions/component.jsx | 300 ++++ .../video-list-item/user-actions/styles.js | 124 ++ .../video-list-item/user-avatar/component.jsx | 63 + .../video-list-item/user-avatar/styles.js | 52 + .../video-list-item/user-status/component.tsx | 34 + .../video-list-item/user-status/styles.js | 36 + .../view-actions/component.tsx | 55 + .../video-list-item/view-actions/styles.ts | 9 + .../ui/components/webcam/container.jsx | 5 +- .../webcam/webcam-graphql/component.tsx | 373 +++++ .../webcam-graphql/drop-areas/component.jsx | 39 + .../webcam-graphql/drop-areas/container.jsx | 15 + .../webcam-graphql/drop-areas/styles.js | 40 + .../core/local-states/createUseLocalState.ts | 2 +- 57 files changed, 6720 insertions(+), 40 deletions(-) create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/adapter.tsx create mode 100755 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/component.tsx create mode 100755 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/container.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/hooks/index.ts create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/many-users-notify/component.jsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/many-users-notify/container.jsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/many-users-notify/styles.js create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/mutations.ts create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/queries.ts create mode 100755 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/service.ts create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/state.ts create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/stream-sorting.ts create mode 100755 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-button/component.jsx create mode 100755 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-button/container.jsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-button/styles.js create mode 100755 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/container.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/styles.ts create mode 100755 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/component.tsx create mode 100755 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/container.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/drag-and-drop/component.jsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/pin-area/component.jsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/pin-area/styles.js create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/styles.ts create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/user-actions/component.jsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/user-actions/styles.js create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/user-avatar/component.jsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/user-avatar/styles.js create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/user-status/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/user-status/styles.js create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/view-actions/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-list/video-list-item/view-actions/styles.ts create mode 100644 bigbluebutton-html5/imports/ui/components/webcam/webcam-graphql/component.tsx create mode 100644 bigbluebutton-html5/imports/ui/components/webcam/webcam-graphql/drop-areas/component.jsx create mode 100644 bigbluebutton-html5/imports/ui/components/webcam/webcam-graphql/drop-areas/container.jsx create mode 100644 bigbluebutton-html5/imports/ui/components/webcam/webcam-graphql/drop-areas/styles.js diff --git a/bigbluebutton-html5/client/main.tsx b/bigbluebutton-html5/client/main.tsx index 54d1fd660d..f1f6b2f805 100644 --- a/bigbluebutton-html5/client/main.tsx +++ b/bigbluebutton-html5/client/main.tsx @@ -14,6 +14,7 @@ import UserGrapQlMiniMongoAdapter from '/imports/ui/components/components-data/u import VoiceUserGrapQlMiniMongoAdapter from '/imports/ui/components/components-data/voiceUserGraphQlMiniMongoAdapter/component'; import MeetingGrapQlMiniMongoAdapter from '/imports/ui/components/components-data/meetingGrapQlMiniMongoAdapter/component'; import ScreenShareGraphQlMiniMongoAdapterContainer from '/imports/ui/components/components-data/screenshareGraphQlMiniMongoAdapter/component'; +import VideoStreamAdapter from '/imports/ui/components/video-provider/video-provider-graphql/adapter'; const Main: React.FC = () => { // Meteor.disconnect(); @@ -31,6 +32,7 @@ const Main: React.FC = () => { + diff --git a/bigbluebutton-html5/imports/startup/client/base.jsx b/bigbluebutton-html5/imports/startup/client/base.jsx index aba10f198e..0ec23bc688 100755 --- a/bigbluebutton-html5/imports/startup/client/base.jsx +++ b/bigbluebutton-html5/imports/startup/client/base.jsx @@ -12,7 +12,7 @@ import AppService from '/imports/ui/components/app/service'; import deviceInfo from '/imports/utils/deviceInfo'; import getFromUserSettings from '/imports/ui/services/users-settings'; import { layoutSelectInput, layoutDispatch } from '../../ui/components/layout/context'; -import VideoService from '/imports/ui/components/video-provider/service'; +import { useVideoStreams } from '/imports/ui/components/video-provider/video-provider-graphql/hooks'; import DebugWindow from '/imports/ui/components/debug-window/component'; import { ACTIONS, PANELS } from '../../ui/components/layout/enums'; import { isChatEnabled } from '/imports/ui/services/features'; @@ -204,8 +204,12 @@ const BaseContainer = (props) => { const sidebarContent = layoutSelectInput((i) => i.sidebarContent); const { sidebarContentPanel } = sidebarContent; const layoutContextDispatch = layoutDispatch(); - const setLocalSettings = useUserChangedLocalSettings(); + const { streams: usersVideo } = useVideoStreams( + props.isGridLayout, + props.paginationEnabled, + props.viewParticipantsWebcams, + ); return ( { sidebarContentPanel, layoutContextDispatch, setLocalSettings, + usersVideo, ...props, }} /> @@ -220,7 +225,7 @@ const BaseContainer = (props) => { }; export default withTracker(() => { - const clientSettings = JSON.parse(sessionStorage.getItem('clientStartupSettings') || '{}') + const clientSettings = JSON.parse(sessionStorage.getItem('clientStartupSettings') || '{}'); const { animations, } = Settings.application; @@ -232,7 +237,7 @@ export default withTracker(() => { let userSubscriptionHandler; const codeError = Session.get('codeError'); - const { streams: usersVideo } = VideoService.getVideoStreams(); + const isGridLayout = Session.get('isGridEnabled'); return { userSubscriptionHandler, animations, @@ -241,6 +246,8 @@ export default withTracker(() => { subscriptionsReady: Session.get('subscriptionsReady') || clientSettings.skipMeteorConnection, loggedIn, codeError, - usersVideo, + paginationEnabled: Settings.application.paginationEnabled, + viewParticipantsWebcams: Settings.dataSaving.viewParticipantsWebcams, + isGridLayout, }; })(BaseContainer); diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx index 745fd8784b..19f30b4856 100755 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx @@ -6,7 +6,7 @@ import AudioCaptionsButtonContainer from '/imports/ui/components/audio/audio-gra import ScreenshareButtonContainer from '/imports/ui/components/actions-bar/screenshare/container'; import ReactionsButtonContainer from './reactions-button/container'; import AudioControlsContainer from '../audio/audio-graphql/audio-controls/component'; -import JoinVideoOptionsContainer from '../video-provider/video-button/container'; +import JoinVideoOptionsContainer from '../video-provider/video-provider-graphql/video-button/container'; import PresentationOptionsContainer from './presentation-options/component'; import RaiseHandDropdownContainer from './raise-hand/container'; import { isPresentationEnabled } from '/imports/ui/services/features'; diff --git a/bigbluebutton-html5/imports/ui/components/app/component.jsx b/bigbluebutton-html5/imports/ui/components/app/component.jsx index fb975f85d1..d83c3b8289 100644 --- a/bigbluebutton-html5/imports/ui/components/app/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/app/component.jsx @@ -53,6 +53,7 @@ import PresentationUploaderToastContainer from '/imports/ui/components/presentat import BreakoutJoinConfirmationContainerGraphQL from '../breakout-join-confirmation/breakout-join-confirmation-graphql/component'; import FloatingWindowContainer from '/imports/ui/components/floating-window/container'; import ChatAlertContainerGraphql from '../chat/chat-graphql/alert/component'; +import VideoStreamAdapter from '/imports/ui/components/video-provider/video-provider-graphql/adapter'; const MOBILE_MEDIA = 'only screen and (max-width: 40em)'; const APP_CONFIG = window.meetingClientSettings.public.app; @@ -577,6 +578,7 @@ setRandomUserSelectModalIsOpen(value) { height: '100%', }} > + {this.renderActivityCheck()} diff --git a/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/component.jsx b/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/component.jsx index 76c0c129d0..cd30e89d34 100755 --- a/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/component.jsx @@ -4,7 +4,7 @@ import ModalFullscreen from '/imports/ui/components/common/modal/fullscreen/comp import logger from '/imports/startup/client/logger'; import PropTypes from 'prop-types'; import AudioService from '../audio/service'; -import VideoService from '../video-provider/service'; +import VideoService from '../video-provider/video-provider-graphql/service'; import { screenshareHasEnded } from '/imports/ui/components/screenshare/service'; import Styled from './styles'; import { Session } from 'meteor/session'; @@ -103,6 +103,8 @@ class BreakoutJoinConfirmation extends Component { requestJoinURL, amIPresenter, sendUserUnshareWebcam, + streams, + exitVideo, } = this.props; const { selectValue } = this.state; @@ -121,8 +123,8 @@ class BreakoutJoinConfirmation extends Component { }, 'joining breakout room closed audio in the main room'); } - VideoService.storeDeviceIds(); - VideoService.exitVideo(sendUserUnshareWebcam); + VideoService.storeDeviceIds(streams); + exitVideo(); if (amIPresenter) screenshareHasEnded(); if (url === '') { logger.error({ diff --git a/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/container.jsx b/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/container.jsx index 32868c977b..7722f882cb 100755 --- a/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-join-confirmation/container.jsx @@ -9,11 +9,13 @@ import BreakoutJoinConfirmationComponent from './component'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; import { BREAKOUT_ROOM_REQUEST_JOIN_URL } from '../breakout-room/mutations'; import { CAMERA_BROADCAST_STOP } from '../video-provider/mutations'; +import { useStreams, useExitVideo } from '/imports/ui/components/video-provider/video-provider-graphql/hooks'; const BreakoutJoinConfirmationContrainer = (props) => { const { data: currentUserData } = useCurrentUser((user) => ({ presenter: user.presenter, })); + const { streams } = useStreams(); const amIPresenter = currentUserData?.presenter; const [breakoutRoomRequestJoinURL] = useMutation(BREAKOUT_ROOM_REQUEST_JOIN_URL); @@ -27,11 +29,15 @@ const BreakoutJoinConfirmationContrainer = (props) => { breakoutRoomRequestJoinURL({ variables: { breakoutRoomId } }); }; + const exitVideo = useExitVideo(); + return }; diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx b/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx index df7a683834..7083cdb5b4 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx @@ -6,7 +6,7 @@ import Styled from './styles'; import Service from './service'; import BreakoutRemainingTime from '/imports/ui/components/common/remaining-time/breakout-duration/component'; import MessageFormContainer from './message-form/container'; -import VideoService from '/imports/ui/components/video-provider/service'; +import VideoService from '/imports/ui/components/video-provider/video-provider-graphql/service'; import { PANELS, ACTIONS } from '../layout/enums'; import { screenshareHasEnded } from '/imports/ui/components/screenshare/service'; import AudioManager from '/imports/ui/services/audio-manager'; @@ -288,6 +288,8 @@ class BreakoutRoom extends PureComponent { setBreakoutAudioTransferStatus, getBreakoutAudioTransferStatus, sendUserUnshareWebcam, + streams, + exitVideo } = this.props; const { @@ -357,8 +359,8 @@ class BreakoutRoom extends PureComponent { logCode: 'breakoutroom_join', extraInfo: { logType: 'user_action' }, }, 'joining breakout room closed audio in the main room'); - VideoService.storeDeviceIds(); - VideoService.exitVideo(sendUserUnshareWebcam); + VideoService.storeDeviceIds(streams); + exitVideo(); if (amIPresenter) screenshareHasEnded(); Tracker.autorun((c) => { diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx b/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx index cf370518cc..39145b9e49 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx @@ -21,6 +21,8 @@ import logger from '/imports/startup/client/logger'; import { CAMERA_BROADCAST_STOP } from '../video-provider/mutations'; import useToggleVoice from '../audio/audio-graphql/hooks/useToggleVoice'; import BreakoutContainerGraphql from '/imports/ui/components/breakout-room/breakout-room-graphql/breakout-room/component'; +import { useStreams } from '/imports/ui/components/video-provider/video-provider-graphql/hooks'; +import { useExitVideo, useStreams } from '/imports/ui/components/video-provider/video-provider-graphql/hooks'; const BreakoutContainer = (props) => { const layoutContextDispatch = layoutDispatch(); @@ -28,6 +30,7 @@ const BreakoutContainer = (props) => { presenter: user.presenter, isModerator: user.isModerator, })); + const { streams } = useStreams(); const amIPresenter = currentUserData?.presenter; const amIModerator = currentUserData?.isModerator; const isRTL = layoutSelect((i) => i.isRTL); diff --git a/bigbluebutton-html5/imports/ui/components/connection-manager/component.tsx b/bigbluebutton-html5/imports/ui/components/connection-manager/component.tsx index 6f998adc3a..bf20f4a82c 100644 --- a/bigbluebutton-html5/imports/ui/components/connection-manager/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/connection-manager/component.tsx @@ -44,7 +44,7 @@ const payloadSizeCheckLink = new ApolloLink((operation, forward) => { } } - logger.debug(`Valid ${operation.operationName} payload. Following with the query.`); + // logger.debug(`Valid ${operation.operationName} payload. Following with the query.`); return forward(operation); }); diff --git a/bigbluebutton-html5/imports/ui/components/connection-status/modal/component.jsx b/bigbluebutton-html5/imports/ui/components/connection-status/modal/component.jsx index f5637edfa2..3b61c51843 100644 --- a/bigbluebutton-html5/imports/ui/components/connection-status/modal/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/connection-status/modal/component.jsx @@ -216,9 +216,10 @@ class ConnectionStatusComponent extends PureComponent { * @return {Promise} A Promise that resolves when process started. */ async startMonitoringNetwork() { - let previousData = await Service.getNetworkData(); + const { streams } = this.props; + let previousData = await Service.getNetworkData(streams); this.rateInterval = Meteor.setInterval(async () => { - const data = await Service.getNetworkData(); + const data = await Service.getNetworkData(streams); const { outbound: audioCurrentUploadRate, diff --git a/bigbluebutton-html5/imports/ui/components/connection-status/modal/container.jsx b/bigbluebutton-html5/imports/ui/components/connection-status/modal/container.jsx index 4678d383bf..d76d8912bf 100644 --- a/bigbluebutton-html5/imports/ui/components/connection-status/modal/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/connection-status/modal/container.jsx @@ -4,16 +4,19 @@ import { CONNECTION_STATUS_REPORT_SUBSCRIPTION } from '../queries'; import Service from '../service'; import Component from './component'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import { useStreams } from '../../video-provider/video-provider-graphql/hooks'; const ConnectionStatusContainer = (props) => { const { data } = useSubscription(CONNECTION_STATUS_REPORT_SUBSCRIPTION); const connectionData = data ? Service.sortConnectionData(data.user_connectionStatusReport) : []; const { data: currentUser } = useCurrentUser((u) => ({ isModerator: u.isModerator })); const amIModerator = !!currentUser?.isModerator; + const { streams } = useStreams(); return ( ); diff --git a/bigbluebutton-html5/imports/ui/components/connection-status/service.js b/bigbluebutton-html5/imports/ui/components/connection-status/service.js index dc646c759c..db3c45475d 100644 --- a/bigbluebutton-html5/imports/ui/components/connection-status/service.js +++ b/bigbluebutton-html5/imports/ui/components/connection-status/service.js @@ -3,7 +3,7 @@ import Auth from '/imports/ui/services/auth'; import { Session } from 'meteor/session'; import { notify } from '/imports/ui/services/notification'; import AudioService from '/imports/ui/components/audio/service'; -import VideoService from '/imports/ui/components/video-provider/service'; +import VideoService from '/imports/ui/components/video-provider/video-provider-graphql/service'; import ScreenshareService from '/imports/ui/components/screenshare/service'; const STATS = window.meetingClientSettings.public.stats; @@ -204,8 +204,8 @@ const getAudioData = async () => { * @returns An Object containing video data for all video peers and screenshare * peer */ -const getVideoData = async () => { - const camerasData = await VideoService.getStats() || {}; +const getVideoData = async (streams) => { + const camerasData = await VideoService.getStats(streams) || {}; const screenshareData = await ScreenshareService.getStats() || {}; @@ -220,10 +220,10 @@ const getVideoData = async () => { * For audio, this will get information about the mic/listen-only stream. * @returns An Object containing all this data. */ -const getNetworkData = async () => { +const getNetworkData = async (streams) => { const audio = await getAudioData(); - const video = await getVideoData(); + const video = await getVideoData(streams); const user = { time: new Date(), diff --git a/bigbluebutton-html5/imports/ui/components/settings/submenus/application/component.jsx b/bigbluebutton-html5/imports/ui/components/settings/submenus/application/component.jsx index 898db0f67c..5bba7886e8 100644 --- a/bigbluebutton-html5/imports/ui/components/settings/submenus/application/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/settings/submenus/application/component.jsx @@ -5,7 +5,7 @@ import LocalesDropdown from '/imports/ui/components/common/locales-dropdown/comp import { defineMessages, injectIntl } from 'react-intl'; import BaseMenu from '../base/component'; import Styled from './styles'; -import VideoService from '/imports/ui/components/video-provider/service'; +import VideoService from '/imports/ui/components/video-provider/video-provider-graphql/service'; import WakeLockService from '/imports/ui/components/wake-lock/service'; import { ACTIONS } from '/imports/ui/components/layout/enums'; import Settings from '/imports/ui/services/settings'; diff --git a/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx b/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx index ddd2327ac1..889734d638 100755 --- a/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx @@ -21,7 +21,7 @@ const SUBSCRIPTIONS = [ // 'users-infos', 'meeting-time-remaining', // 'record-meetings', - 'video-streams', + // 'video-streams', // 'voice-call-states', // 'breakouts', // 'breakouts-history', diff --git a/bigbluebutton-html5/imports/ui/components/user-list/service.js b/bigbluebutton-html5/imports/ui/components/user-list/service.js index c2c889a503..f19d43b89d 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/service.js +++ b/bigbluebutton-html5/imports/ui/components/user-list/service.js @@ -10,7 +10,7 @@ import { EMOJI_STATUSES } from '/imports/utils/statuses'; import { makeCall } from '/imports/ui/services/api'; import KEY_CODES from '/imports/utils/keyCodes'; import AudioService from '/imports/ui/components/audio/service'; -import VideoService from '/imports/ui/components/video-provider/service'; +import VideoService from '/imports/ui/components/video-provider/video-provider-graphql/service'; import UserReactionService from '/imports/ui/components/user-reaction/service'; import logger from '/imports/startup/client/logger'; import { Session } from 'meteor/session'; diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx b/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx index d7da1fa6ad..01e26e5e31 100755 --- a/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx @@ -8,7 +8,7 @@ import VirtualBgSelector from '/imports/ui/components/video-preview/virtual-back import logger from '/imports/startup/client/logger'; import browserInfo from '/imports/utils/browserInfo'; import PreviewService from './service'; -import VideoService from '../video-provider/service'; +import VideoService from '../video-provider/video-provider-graphql/service'; import Styled from './styles'; import deviceInfo from '/imports/utils/deviceInfo'; import MediaStreamUtils from '/imports/utils/media-stream-utils'; diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx b/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx index a64d1f462c..11ddaca8a7 100755 --- a/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx @@ -3,15 +3,24 @@ import { useMutation } from '@apollo/client'; import { withTracker } from 'meteor/react-meteor-data'; import Service from './service'; import VideoPreview from './component'; -import VideoService from '../video-provider/service'; +import VideoService from '../video-provider/video-provider-graphql/service'; import ScreenShareService from '/imports/ui/components/screenshare/service'; import logger from '/imports/startup/client/logger'; import { SCREENSHARING_ERRORS } from '/imports/api/screenshare/client/bridge/errors'; import { EXTERNAL_VIDEO_STOP } from '../external-video-player/mutations'; import { CAMERA_BROADCAST_STOP } from '../video-provider/mutations'; +import { + useSharedDevices, useHasVideoStream, useHasCapReached, useIsUserLocked, useStreams, + useExitVideo, +} from '/imports/ui/components/video-provider/video-provider-graphql/hooks'; const VideoPreviewContainer = (props) => { - const { buildStartSharingCameraAsContent, buildStopSharing, ...rest } = props; + const { + buildStartSharingCameraAsContent, + buildStopSharing, + hasCapReached, + ...rest + } = props; const [stopExternalVideoShare] = useMutation(EXTERNAL_VIDEO_STOP); const [cameraBroadcastStop] = useMutation(CAMERA_BROADCAST_STOP); @@ -19,14 +28,25 @@ const VideoPreviewContainer = (props) => { cameraBroadcastStop({ variables: { cameraId } }); }; + const { streams } = useStreams(); + const exitVideo = useExitVideo() const startSharingCameraAsContent = buildStartSharingCameraAsContent(stopExternalVideoShare); - const stopSharing = buildStopSharing(sendUserUnshareWebcam); + const stopSharing = buildStopSharing(sendUserUnshareWebcam, streams, exitVideo); + const sharedDevices = useSharedDevices(); + const hasVideoStream = useHasVideoStream(); + const camCapReached = useHasCapReached(); + const isCamLocked = useIsUserLocked(); + return ( @@ -57,18 +77,18 @@ export default withTracker(({ setIsOpen, callbackToClose }) => ({ }; ScreenShareService.shareScreen( stopExternalVideoShare, - true, handleFailure, { stream: Service.getStream(deviceId)._mediaStream } + true, handleFailure, { stream: Service.getStream(deviceId)._mediaStream }, ); ScreenShareService.setCameraAsContentDeviceId(deviceId); }, - buildStopSharing: (sendUserUnshareWebcam) => (deviceId) => { + buildStopSharing: (sendUserUnshareWebcam, streams, exitVideo) => (deviceId) => { callbackToClose(); setIsOpen(false); if (deviceId) { - const streamId = VideoService.getMyStreamId(deviceId); - if (streamId) VideoService.stopVideo(streamId, sendUserUnshareWebcam); + const streamId = VideoService.getMyStreamId(deviceId, streams); + if (streamId) VideoService.stopVideo(streamId, sendUserUnshareWebcam, streams); } else { - VideoService.exitVideo(sendUserUnshareWebcam); + exitVideo(sendUserUnshareWebcam, streams); } }, stopSharingCameraAsContent: () => { @@ -76,14 +96,10 @@ export default withTracker(({ setIsOpen, callbackToClose }) => ({ setIsOpen(false); ScreenShareService.screenshareHasEnded(); }, - sharedDevices: VideoService.getSharedDevices(), cameraAsContentDeviceId: ScreenShareService.getCameraAsContentDeviceId(), - isCamLocked: VideoService.isUserLocked(), - camCapReached: VideoService.hasCapReached(), closeModal: () => { callbackToClose(); setIsOpen(false); }, webcamDeviceId: Service.webcamDeviceId(), - hasVideoStream: VideoService.hasVideoStream(), }))(VideoPreviewContainer); diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/service.js b/bigbluebutton-html5/imports/ui/components/video-preview/service.js index 52f0ccdbd4..6ba5b8586e 100755 --- a/bigbluebutton-html5/imports/ui/components/video-preview/service.js +++ b/bigbluebutton-html5/imports/ui/components/video-preview/service.js @@ -2,7 +2,7 @@ import Storage from '/imports/ui/services/storage/session'; import BBBStorage from '/imports/ui/services/storage'; import getFromUserSettings from '/imports/ui/services/users-settings'; import MediaStreamUtils from '/imports/utils/media-stream-utils'; -import VideoService from '/imports/ui/components/video-provider/service'; +import VideoService from '/imports/ui/components/video-provider/video-provider-graphql/service'; import BBBVideoStream from '/imports/ui/services/webrtc-base/bbb-video-stream'; import browserInfo from '/imports/utils/browserInfo'; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/container.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/container.jsx index 2558771b1c..e7fc4a391d 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/container.jsx @@ -9,6 +9,7 @@ import { getVideoData, getVideoDataGrid } from './queries'; import useMeeting from '/imports/ui/core/hooks/useMeeting'; import Auth from '/imports/ui/services/auth'; import useCurrentUser from '../../core/hooks/useCurrentUser'; +import VideoProviderContainerGraphql from './video-provider-graphql/container'; const { defaultSorting: DEFAULT_SORTING } = window.meetingClientSettings.public.kurento.cameraSortingModes; @@ -47,7 +48,7 @@ const VideoProviderContainer = ({ children, ...props }) => { ); }; -export default withTracker(({ swapLayout, ...rest }) => { +withTracker(({ swapLayout, ...rest }) => { const isGridLayout = Session.get('isGridEnabled'); const graphqlQuery = isGridLayout ? getVideoDataGrid : getVideoData; const currUserId = Auth.userID; @@ -123,3 +124,5 @@ export default withTracker(({ swapLayout, ...rest }) => { ...rest, }; })(VideoProviderContainer); + +export default VideoProviderContainerGraphql; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/adapter.tsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/adapter.tsx new file mode 100644 index 0000000000..fb14dddebe --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/adapter.tsx @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +import { useSubscription } from '@apollo/client'; +import { + VIDEO_STREAMS_SUBSCRIPTION, + VideoStreamsResponse, +} from './queries'; +import { setStreams } from './state'; + +const VideoStreamAdapter: React.FC = () => { + const { data, loading, error } = useSubscription(VIDEO_STREAMS_SUBSCRIPTION); + + useEffect(() => { + if (loading || error) return; + + if (!data) { + setStreams([]); + return; + } + + const streams = data.user_camera.map(({ streamId, user, voice }) => ({ + stream: streamId, + deviceId: streamId.split('_')[2], + userId: user.userId, + name: user.name, + sortName: user.nameSortable, + pin: user.pinned, + floor: voice?.floor || false, + lastFloorTime: voice?.lastFloorTime || '0', + isUserModerator: user.isModerator, + })); + + setStreams(streams); + }, [data]); + + return null; +}; + +export default VideoStreamAdapter; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/component.tsx new file mode 100755 index 0000000000..526d3d3b1a --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/component.tsx @@ -0,0 +1,1291 @@ +/* eslint react/sort-comp: 0 */ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ReconnectingWebSocket from 'reconnecting-websocket'; +import { defineMessages, injectIntl } from 'react-intl'; +import { debounce } from '/imports/utils/debounce'; +import VideoService from './service'; +import VideoListContainer from './video-list/container'; +import { + fetchWebRTCMappedStunTurnServers, + getMappedFallbackStun, +} from '/imports/utils/fetchStunTurnServers'; +import logger from '/imports/startup/client/logger'; +import { notifyStreamStateChange } from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service'; +import VideoPreviewService from '/imports/ui/components/video-preview/service'; +import MediaStreamUtils from '/imports/utils/media-stream-utils'; +import BBBVideoStream from '/imports/ui/services/webrtc-base/bbb-video-stream'; +import { + EFFECT_TYPES, + getSessionVirtualBackgroundInfo, +} from '/imports/ui/services/virtual-background/service'; +import { notify } from '/imports/ui/services/notification'; +import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils'; +import WebRtcPeer from '/imports/ui/services/webrtc-base/peer'; + +// Default values and default empty object to be backwards compat with 2.2. +// FIXME Remove hardcoded defaults 2.3. +const { + connectionTimeout: WS_CONN_TIMEOUT = 4000, + maxRetries: WS_MAX_RETRIES = 5, + debug: WS_DEBUG, + heartbeat: WS_HEARTBEAT_OPTS = { + interval: 15000, + delay: 3000, + reconnectOnFailure: true, + }, +} = window.meetingClientSettings.public.kurento.cameraWsOptions; + +const { webcam: NETWORK_PRIORITY } = window.meetingClientSettings.public.media.networkPriorities || {}; +const { + baseTimeout: CAMERA_SHARE_FAILED_WAIT_TIME = 15000, + maxTimeout: MAX_CAMERA_SHARE_FAILED_WAIT_TIME = 60000, +} = window.meetingClientSettings.public.kurento.cameraTimeouts || {}; +const { + enabled: CAMERA_QUALITY_THRESHOLDS_ENABLED = true, + privilegedStreams: CAMERA_QUALITY_THR_PRIVILEGED = true, +} = window.meetingClientSettings.public.kurento.cameraQualityThresholds; +const SIGNAL_CANDIDATES = window.meetingClientSettings.public.kurento.signalCandidates; +const TRACE_LOGS = window.meetingClientSettings.public.kurento.traceLogs; +const GATHERING_TIMEOUT = window.meetingClientSettings.public.kurento.gatheringTimeout; + +const intlClientErrors = defineMessages({ + permissionError: { + id: 'app.video.permissionError', + description: 'Webcam permission error', + }, + iceConnectionStateError: { + id: 'app.video.iceConnectionStateError', + description: 'Ice connection state failed', + }, + mediaFlowTimeout: { + id: 'app.video.mediaFlowTimeout1020', + description: 'Media flow timeout', + }, + mediaTimedOutError: { + id: 'app.video.mediaTimedOutError', + description: 'Media was ejected by the server due to lack of valid media', + }, + virtualBgGenericError: { + id: 'app.video.virtualBackground.genericError', + description: 'Failed to apply camera effect', + }, + inactiveError: { + id: 'app.video.inactiveError', + description: 'Camera stopped unexpectedly', + }, +}); + +const intlSFUErrors = defineMessages({ + 2000: { + id: 'app.sfu.mediaServerConnectionError2000', + description: 'SFU connection to the media server', + }, + 2001: { + id: 'app.sfu.mediaServerOffline2001', + description: 'SFU is offline', + }, + 2002: { + id: 'app.sfu.mediaServerNoResources2002', + description: 'Media server lacks disk, CPU or FDs', + }, + 2003: { + id: 'app.sfu.mediaServerRequestTimeout2003', + description: 'Media requests timeout due to lack of resources', + }, + 2021: { + id: 'app.sfu.serverIceGatheringFailed2021', + description: 'Server cannot enact ICE gathering', + }, + 2022: { + id: 'app.sfu.serverIceStateFailed2022', + description: 'Server endpoint transitioned to a FAILED ICE state', + }, + 2200: { + id: 'app.sfu.mediaGenericError2200', + description: 'SFU component generated a generic error', + }, + 2202: { + id: 'app.sfu.invalidSdp2202', + description: 'Client provided an invalid SDP', + }, + 2203: { + id: 'app.sfu.noAvailableCodec2203', + description: 'Server has no available codec for the client', + }, +}); + +const propTypes = { + streams: PropTypes.arrayOf(Array).isRequired, + intl: PropTypes.objectOf(Object).isRequired, + isUserLocked: PropTypes.bool.isRequired, + swapLayout: PropTypes.bool.isRequired, + currentVideoPageIndex: PropTypes.number.isRequired, + totalNumberOfStreams: PropTypes.number.isRequired, + isMeteorConnected: PropTypes.bool.isRequired, + playStart: PropTypes.func.isRequired, + sendUserUnshareWebcam: PropTypes.func.isRequired, +}; + +class VideoProviderGraphql extends Component { + onBeforeUnload() { + const { sendUserUnshareWebcam } = this.props; + VideoService.onBeforeUnload(sendUserUnshareWebcam); + } + + static shouldAttachVideoStream(peer, videoElement) { + // Conditions to safely attach a stream to a video element in all browsers: + // 1 - Peer exists, video element exists + // 2 - Target stream differs from videoElement's (diff) + // 3a - If the stream is a remote one, the safest (*ahem* Safari) moment to + // do so is waiting for the server to confirm that media has flown out of it + // towards te remote end (peer.started) + // 3b - If the stream is a local one (webcam sharer) and is started + // 4 - If the stream is local one, check if there area video tracks there are + // video tracks: attach it + if (peer == null || videoElement == null) return false; + const stream = peer.isPublisher ? peer.getLocalStream() : peer.getRemoteStream(); + const diff = stream && (stream.id !== videoElement.srcObject?.id || !videoElement.paused); + + if (peer.started && diff) return true; + + return peer.isPublisher + && peer.getLocalStream() + && peer.getLocalStream().getVideoTracks().length > 0 + && diff; + } + + constructor(props) { + super(props); + + // socketOpen state is there to force update when the signaling socket opens or closes + this.state = { + socketOpen: false, + }; + this._isMounted = false; + this.info = VideoService.getInfo(); + // Signaling message queue arrays indexed by stream (== cameraId) + this.wsQueues = {}; + this.restartTimeout = {}; + this.restartTimer = {}; + this.webRtcPeers = {}; + this.outboundIceQueues = {}; + this.videoTags = {}; + + this.createVideoTag = this.createVideoTag.bind(this); + this.destroyVideoTag = this.destroyVideoTag.bind(this); + this.onWsOpen = this.onWsOpen.bind(this); + this.onWsClose = this.onWsClose.bind(this); + this.onWsMessage = this.onWsMessage.bind(this); + this.updateStreams = this.updateStreams.bind(this); + this.connectStreams = this.connectStreams.bind(this); + this.debouncedConnectStreams = debounce( + this.connectStreams, + VideoService.getPageChangeDebounceTime(), + { leading: false, trailing: true }, + ); + this.startVirtualBackgroundByDrop = this.startVirtualBackgroundByDrop.bind(this); + this.onBeforeUnload = this.onBeforeUnload.bind(this); + } + + componentDidMount() { + this._isMounted = true; + VideoService.updatePeerDictionaryReference(this.webRtcPeers); + this.ws = this.openWs(); + window.addEventListener('beforeunload', this.onBeforeUnload); + } + + componentDidUpdate(prevProps) { + const { + isUserLocked, + streams, + currentVideoPageIndex, + isMeteorConnected, + sendUserUnshareWebcam, + } = this.props; + const { socketOpen } = this.state; + + // Only debounce when page changes to avoid unnecessary debouncing + const shouldDebounce = VideoService.isPaginationEnabled() + && prevProps.currentVideoPageIndex !== currentVideoPageIndex; + + if (isMeteorConnected && socketOpen) this.updateStreams(streams, shouldDebounce); + if (!prevProps.isUserLocked && isUserLocked) { + VideoService.lockUser(sendUserUnshareWebcam, streams); + } + + // Signaling socket expired its retries and meteor is connected - create + // a new signaling socket instance from scratch + if (!socketOpen + && isMeteorConnected + && this.ws == null) { + this.ws = this.openWs(); + } + } + + componentWillUnmount() { + const { sendUserUnshareWebcam, streams, exitVideo } = this.props; + this._isMounted = false; + VideoService.updatePeerDictionaryReference({}); + + this.ws.onmessage = null; + this.ws.onopen = null; + this.ws.onclose = null; + + window.removeEventListener('beforeunload', this.onBeforeUnload); + exitVideo(); + Object.keys(this.webRtcPeers).forEach((stream) => { + this.stopWebRTCPeer(stream, false); + }); + this.terminateWs(); + } + + openWs() { + const ws = new ReconnectingWebSocket( + VideoService.getAuthenticatedURL(), [], { + connectionTimeout: WS_CONN_TIMEOUT, + debug: WS_DEBUG, + maxRetries: WS_MAX_RETRIES, + maxEnqueuedMessages: 0, + } + ); + ws.onopen = this.onWsOpen; + ws.onclose = this.onWsClose; + ws.onmessage = this.onWsMessage; + + return ws; + } + + terminateWs() { + if (this.ws) { + this.clearWSHeartbeat(); + this.ws.close(); + this.ws = null; + } + } + + _updateLastMsgTime() { + this.ws.isAlive = true; + this.ws.lastMsgTime = Date.now(); + } + + _getTimeSinceLastMsg() { + return Date.now() - this.ws.lastMsgTime; + } + + setupWSHeartbeat() { + if (WS_HEARTBEAT_OPTS.interval === 0 || this.ws == null || this.ws.wsHeartbeat) return; + + this.ws.isAlive = true; + this.ws.wsHeartbeat = setInterval(() => { + if (this.ws.isAlive === false) { + logger.warn({ + logCode: 'video_provider_ws_heartbeat_failed', + }, 'Video provider WS heartbeat failed.'); + + if (WS_HEARTBEAT_OPTS.reconnectOnFailure) this.ws.reconnect(); + return; + } + + if (this._getTimeSinceLastMsg() < ( + WS_HEARTBEAT_OPTS.interval - WS_HEARTBEAT_OPTS.delay + )) { + return; + } + + this.ws.isAlive = false; + this.ping(); + }, WS_HEARTBEAT_OPTS.interval); + + this.ping(); + } + + clearWSHeartbeat() { + if (this.ws?.wsHeartbeat) { + clearInterval(this.ws.wsHeartbeat); + this.ws.wsHeartbeat = null; + } + } + + onWsMessage(message) { + this._updateLastMsgTime(); + const parsedMessage = JSON.parse(message.data); + + if (parsedMessage.id === 'pong') return; + + switch (parsedMessage.id) { + case 'startResponse': + this.startResponse(parsedMessage); + break; + + case 'playStart': + this.handlePlayStart(parsedMessage); + break; + + case 'playStop': + this.handlePlayStop(parsedMessage); + break; + + case 'iceCandidate': + this.handleIceCandidate(parsedMessage); + break; + + case 'pong': + break; + + case 'error': + default: + this.handleSFUError(parsedMessage); + break; + } + } + + onWsClose() { + const { sendUserUnshareWebcam, streams, exitVideo } = this.props; + logger.info({ + logCode: 'video_provider_onwsclose', + }, 'Multiple video provider websocket connection closed.'); + + this.clearWSHeartbeat(); + exitVideo(); + // Media is currently tied to signaling state - so if signaling shuts down, + // media will shut down server-side. This cleans up our local state faster + // and notify the state change as failed so the UI rolls back to the placeholder + // avatar UI in the camera container + Object.keys(this.webRtcPeers).forEach((stream) => { + if (this.stopWebRTCPeer(stream, false)) { + notifyStreamStateChange(stream, 'failed'); + } + }); + this.setState({ socketOpen: false }); + + if (this.ws && this.ws.retryCount >= WS_MAX_RETRIES) { + this.terminateWs(); + } + } + + onWsOpen() { + logger.info({ + logCode: 'video_provider_onwsopen', + }, 'Multiple video provider websocket connection opened.'); + + this._updateLastMsgTime(); + this.setupWSHeartbeat(); + this.setState({ socketOpen: true }); + // Resend queued messages that happened when socket was not connected + Object.entries(this.wsQueues).forEach(([stream, queue]) => { + if (this.webRtcPeers[stream]) { + // Peer - send enqueued + while (queue.length > 0) { + this.sendMessage(queue.pop()); + } + } else { + // No peer - delete queue + this.wsQueues[stream] = null; + } + }); + } + + findAllPrivilegedStreams () { + const { streams } = this.props; + // Privileged streams are: floor holders, pinned users + return streams.filter(stream => stream.floor || stream.pin); + } + + updateQualityThresholds(numberOfPublishers) { + const { threshold, profile } = VideoService.getThreshold(numberOfPublishers); + if (profile) { + const privilegedStreams = this.findAllPrivilegedStreams(); + Object.values(this.webRtcPeers) + .filter(peer => peer.isPublisher) + .forEach((peer) => { + // Conditions which make camera revert their original profile + // 1) Threshold 0 means original profile/inactive constraint + // 2) Privileged streams + const exempt = threshold === 0 + || (CAMERA_QUALITY_THR_PRIVILEGED && privilegedStreams.some(vs => vs.stream === peer.stream)) + const profileToApply = exempt ? peer.originalProfileId : profile; + VideoService.applyCameraProfile(peer, profileToApply); + }); + } + } + + getStreamsToConnectAndDisconnect(streams) { + const streamsCameraIds = streams.filter(s => !s?.isGridItem).map(s => s.stream); + const streamsConnected = Object.keys(this.webRtcPeers); + + const streamsToConnect = streamsCameraIds.filter(stream => { + return !streamsConnected.includes(stream); + }); + + const streamsToDisconnect = streamsConnected.filter(stream => { + return !streamsCameraIds.includes(stream); + }); + + return [streamsToConnect, streamsToDisconnect]; + } + + connectStreams(streamsToConnect) { + streamsToConnect.forEach((stream) => { + const isLocal = VideoService.isLocalStream(stream); + this.createWebRTCPeer(stream, isLocal); + }); + } + + disconnectStreams(streamsToDisconnect) { + streamsToDisconnect.forEach(stream => this.stopWebRTCPeer(stream, false)); + } + + updateStreams(streams, shouldDebounce = false) { + const [streamsToConnect, streamsToDisconnect] = this.getStreamsToConnectAndDisconnect(streams); + + if (shouldDebounce) { + this.debouncedConnectStreams(streamsToConnect); + } else { + this.connectStreams(streamsToConnect); + } + + this.disconnectStreams(streamsToDisconnect); + + if (CAMERA_QUALITY_THRESHOLDS_ENABLED) { + this.updateQualityThresholds(this.props.totalNumberOfStreams); + } + } + + ping() { + const message = { id: 'ping' }; + this.sendMessage(message); + } + + sendMessage(message) { + const { ws } = this; + + if (this.connectedToMediaServer()) { + const jsonMessage = JSON.stringify(message); + try { + ws.send(jsonMessage); + } catch (error) { + logger.error({ + logCode: 'video_provider_ws_send_error', + extraInfo: { + errorMessage: error.message || 'Unknown', + errorCode: error.code, + }, + }, 'Camera request failed to be sent to SFU'); + } + } else if (message.id !== 'stop') { + // No need to queue video stop messages + const { cameraId } = message; + if (cameraId) { + if (this.wsQueues[cameraId] == null) this.wsQueues[cameraId] = []; + this.wsQueues[cameraId].push(message); + } + } + } + + connectedToMediaServer() { + return this.ws && this.ws.readyState === ReconnectingWebSocket.OPEN; + } + + processOutboundIceQueue(peer, role, stream) { + const queue = this.outboundIceQueues[stream]; + while (queue && queue.length) { + const candidate = queue.shift(); + this.sendIceCandidateToSFU(peer, role, candidate, stream); + } + } + + sendLocalAnswer (peer, stream, answer) { + const message = { + id: 'subscriberAnswer', + type: 'video', + role: VideoService.getRole(peer.isPublisher), + cameraId: stream, + answer, + }; + + this.sendMessage(message); + } + + startResponse(message) { + const { cameraId: stream, role } = message; + const peer = this.webRtcPeers[stream]; + + logger.debug({ + logCode: 'video_provider_start_response_success', + extraInfo: { cameraId: stream, role }, + }, `Camera start request accepted by SFU. Role: ${role}`); + + if (peer) { + const processorFunc = peer.isPublisher + ? peer.processAnswer.bind(peer) + : peer.processOffer.bind(peer); + + processorFunc(message.sdpAnswer).then((answer) => { + if (answer) this.sendLocalAnswer(peer, stream, answer); + + peer.didSDPAnswered = true; + this.processOutboundIceQueue(peer, role, stream); + VideoService.processInboundIceQueue(peer, stream); + }).catch((error) => { + logger.error({ + logCode: 'video_provider_peerconnection_process_error', + extraInfo: { + cameraId: stream, + role, + errorMessage: error.message, + errorCode: error.code, + }, + }, 'Camera answer processing failed'); + }); + } else { + logger.warn({ + logCode: 'video_provider_startresponse_no_peer', + extraInfo: { cameraId: stream, role }, + }, 'No peer on SFU camera start response handler'); + } + } + + handleIceCandidate(message) { + const { cameraId: stream, candidate } = message; + const peer = this.webRtcPeers[stream]; + + if (peer) { + if (peer.didSDPAnswered) { + VideoService.addCandidateToPeer(peer, candidate, stream); + } else { + // ICE candidates are queued until a SDP answer has been processed. + // This was done due to a long term iOS/Safari quirk where it'd + // fail if candidates were added before the offer/answer cycle was completed. + // Dunno if that still happens, but it works even if it slows the ICE checks + // a bit - prlanzarin july 2019 + if (peer.inboundIceQueue == null) { + peer.inboundIceQueue = []; + } + peer.inboundIceQueue.push(candidate); + } + } else { + logger.warn({ + logCode: 'video_provider_addicecandidate_no_peer', + extraInfo: { cameraId: stream }, + }, 'Trailing camera ICE candidate, discarded'); + } + } + + clearRestartTimers(stream) { + if (this.restartTimeout[stream]) { + clearTimeout(this.restartTimeout[stream]); + delete this.restartTimeout[stream]; + } + + if (this.restartTimer[stream]) { + delete this.restartTimer[stream]; + } + } + + stopWebRTCPeer(stream, restarting = false) { + const isLocal = VideoService.isLocalStream(stream); + const { sendUserUnshareWebcam, streams } = this.props; + + // in this case, 'closed' state is not caused by an error; + // we stop listening to prevent this from being treated as an error + const peer = this.webRtcPeers[stream]; + if (peer && peer.peerConnection) { + const conn = peer.peerConnection; + conn.oniceconnectionstatechange = null; + } + + if (isLocal) { + VideoService.stopVideo(stream, sendUserUnshareWebcam, streams); + } + + const role = VideoService.getRole(isLocal); + + logger.info({ + logCode: 'video_provider_stopping_webcam_sfu', + extraInfo: { role, cameraId: stream, restarting }, + }, `Camera feed stop requested. Role ${role}, restarting ${restarting}`); + + this.sendMessage({ + id: 'stop', + type: 'video', + cameraId: stream, + role, + }); + + // Clear the shared camera media flow timeout and current reconnect period + // when destroying it if the peer won't restart + if (!restarting) { + this.clearRestartTimers(stream); + } + + return this.destroyWebRTCPeer(stream); + } + + destroyWebRTCPeer(stream) { + let stopped = false; + const peer = this.webRtcPeers[stream]; + const isLocal = VideoService.isLocalStream(stream); + const role = VideoService.getRole(isLocal); + + if (peer) { + if (peer && peer.bbbVideoStream) { + if (typeof peer.inactivationHandler === 'function') { + peer.bbbVideoStream.removeListener('inactive', peer.inactivationHandler); + } + peer.bbbVideoStream.stop(); + } + + if (typeof peer.dispose === 'function') { + peer.dispose(); + } + + delete this.webRtcPeers[stream]; + stopped = true; + } else { + logger.warn({ + logCode: 'video_provider_destroywebrtcpeer_no_peer', + extraInfo: { cameraId: stream, role }, + }, 'Trailing camera destroy request.'); + } + + delete this.outboundIceQueues[stream]; + delete this.wsQueues[stream]; + + return stopped; + } + + _createPublisher(stream, peerOptions) { + return new Promise((resolve, reject) => { + try { + const { id: profileId } = VideoService.getCameraProfile(); + let bbbVideoStream = VideoService.getPreloadedStream(); + + if (bbbVideoStream) { + peerOptions.videoStream = bbbVideoStream.mediaStream; + } + + const peer = new WebRtcPeer('sendonly', peerOptions); + peer.bbbVideoStream = bbbVideoStream; + this.webRtcPeers[stream] = peer; + peer.stream = stream; + peer.started = false; + peer.didSDPAnswered = false; + peer.inboundIceQueue = []; + peer.isPublisher = true; + peer.originalProfileId = profileId; + peer.currentProfileId = profileId; + peer.start(); + peer.generateOffer().then((offer) => { + // Store the media stream if necessary. The scenario here is one where + // there is no preloaded stream stored. + if (peer.bbbVideoStream == null) { + bbbVideoStream = new BBBVideoStream(peer.getLocalStream()); + VideoPreviewService.storeStream( + MediaStreamUtils.extractDeviceIdFromStream( + bbbVideoStream.mediaStream, + 'video', + ), + bbbVideoStream, + ); + } + + peer.bbbVideoStream = bbbVideoStream; + bbbVideoStream.on('streamSwapped', ({ newStream }) => { + if (newStream && newStream instanceof MediaStream) { + this.replacePCVideoTracks(stream, newStream); + } + }); + peer.inactivationHandler = () => this._handleLocalStreamInactive(stream); + bbbVideoStream.once('inactive', peer.inactivationHandler); + resolve(offer); + }).catch(reject); + } catch (error) { + reject(error); + } + }); + } + + _createSubscriber(stream, peerOptions) { + return new Promise((resolve, reject) => { + try { + const peer = new WebRtcPeer('recvonly', peerOptions); + this.webRtcPeers[stream] = peer; + peer.stream = stream; + peer.started = false; + peer.didSDPAnswered = false; + peer.inboundIceQueue = []; + peer.isPublisher = false; + peer.start(); + resolve(); + } catch (error) { + reject(error); + } + }); + } + + async createWebRTCPeer(stream, isLocal) { + let iceServers = []; + const role = VideoService.getRole(isLocal); + const peerBuilderFunc = isLocal + ? this._createPublisher.bind(this) + : this._createSubscriber.bind(this); + + // Check if the peer is already being processed + if (this.webRtcPeers[stream]) { + return; + } + + this.webRtcPeers[stream] = {}; + this.outboundIceQueues[stream] = []; + const { constraints, bitrate } = VideoService.getCameraProfile(); + const peerOptions = { + mediaConstraints: { + audio: false, + video: constraints, + }, + onicecandidate: this._getOnIceCandidateCallback(stream, isLocal), + configuration: { + }, + trace: TRACE_LOGS, + networkPriorities: NETWORK_PRIORITY ? { video: NETWORK_PRIORITY } : undefined, + gatheringTimeout: GATHERING_TIMEOUT, + }; + + try { + iceServers = await fetchWebRTCMappedStunTurnServers(this.info.sessionToken); + } catch (error) { + logger.error({ + logCode: 'video_provider_fetchstunturninfo_error', + extraInfo: { + cameraId: stream, + role, + errorCode: error.code, + errorMessage: error.message, + }, + }, 'video-provider failed to fetch STUN/TURN info, using default'); + // Use fallback STUN server + iceServers = getMappedFallbackStun(); + } finally { + // we need to set iceTransportPolicy after `fetchWebRTCMappedStunTurnServers` + // because `shouldForceRelay` uses the information from the stun API + peerOptions.configuration.iceTransportPolicy = shouldForceRelay() ? 'relay' : undefined; + if (iceServers.length > 0) { + peerOptions.configuration.iceServers = iceServers; + } + + peerBuilderFunc(stream, peerOptions).then((offer) => { + if (!this._isMounted) { + return this.stopWebRTCPeer(stream, false); + } + const peer = this.webRtcPeers[stream]; + + if (peer && peer.peerConnection) { + const conn = peer.peerConnection; + conn.onconnectionstatechange = () => { + this._handleIceConnectionStateChange(stream, isLocal); + }; + } + + const message = { + id: 'start', + type: 'video', + cameraId: stream, + role, + sdpOffer: offer, + bitrate, + record: VideoService.getRecord(), + mediaServer: VideoService.getMediaServerAdapter(), + }; + + logger.info({ + logCode: 'video_provider_sfu_request_start_camera', + extraInfo: { + cameraId: stream, + role, + }, + }, `Camera offer generated. Role: ${role}`); + + this.setReconnectionTimeout(stream, isLocal, false); + this.sendMessage(message); + + return; + }).catch(error => { + return this._onWebRTCError(error, stream, isLocal); + }); + } + } + + _getWebRTCStartTimeout(stream, isLocal) { + const { intl } = this.props; + + return () => { + const role = VideoService.getRole(isLocal); + if (!isLocal) { + // Peer that timed out is a subscriber/viewer + // Subscribers try to reconnect according to their timers if media could + // not reach the server. That's why we pass the restarting flag as true + // to the stop procedure as to not destroy the timers + // Create new reconnect interval time + const oldReconnectTimer = this.restartTimer[stream]; + const newReconnectTimer = Math.min( + 2 * oldReconnectTimer, + MAX_CAMERA_SHARE_FAILED_WAIT_TIME, + ); + this.restartTimer[stream] = newReconnectTimer; + + // Clear the current reconnect interval so it can be re-set in createWebRTCPeer + if (this.restartTimeout[stream]) { + delete this.restartTimeout[stream]; + } + + logger.error({ + logCode: 'video_provider_camera_view_timeout', + extraInfo: { + cameraId: stream, + role, + oldReconnectTimer, + newReconnectTimer, + }, + }, 'Camera VIEWER failed. Reconnecting.'); + + this.reconnect(stream, isLocal); + } else { + // Peer that timed out is a sharer/publisher, clean it up, stop. + logger.error({ + logCode: 'video_provider_camera_share_timeout', + extraInfo: { + cameraId: stream, + role, + }, + }, 'Camera SHARER failed.'); + VideoService.notify(intl.formatMessage(intlClientErrors.mediaFlowTimeout)); + this.stopWebRTCPeer(stream, false); + } + }; + } + + _onWebRTCError(error, stream, isLocal) { + const { intl, streams } = this.props; + const { name: errorName, message: errorMessage } = error; + const errorLocale = intlClientErrors[errorName] + || intlClientErrors[errorMessage] + || intlSFUErrors[error]; + + logger.error({ + logCode: 'video_provider_webrtc_peer_error', + extraInfo: { + cameraId: stream, + role: VideoService.getRole(isLocal), + errorName: error.name, + errorMessage: error.message, + }, + }, 'Camera peer failed'); + + // Only display WebRTC negotiation error toasts to sharers. The viewer streams + // will try to autoreconnect silently, but the error will log nonetheless + if (isLocal) { + this.stopWebRTCPeer(stream, false); + if (errorLocale) VideoService.notify(intl.formatMessage(errorLocale)); + } else { + // If it's a viewer, set the reconnection timeout. There's a good chance + // no local candidate was generated and it wasn't set. + const peer = this.webRtcPeers[stream]; + const stillExists = streams.some(({ stream: streamId }) => streamId === stream); + + if (stillExists) { + const isEstablishedConnection = peer && peer.started; + this.setReconnectionTimeout(stream, isLocal, isEstablishedConnection); + } + + // second argument means it will only try to reconnect if + // it's a viewer instance (see stopWebRTCPeer restarting argument) + this.stopWebRTCPeer(stream, stillExists); + } + } + + reconnect(stream, isLocal) { + this.stopWebRTCPeer(stream, true); + this.createWebRTCPeer(stream, isLocal); + } + + setReconnectionTimeout(stream, isLocal, isEstablishedConnection) { + const peer = this.webRtcPeers[stream]; + const shouldSetReconnectionTimeout = !this.restartTimeout[stream] && !isEstablishedConnection; + + // This is an ongoing reconnection which succeeded in the first place but + // then failed mid call. Try to reconnect it right away. Clear the restart + // timers since we don't need them in this case. + if (isEstablishedConnection) { + this.clearRestartTimers(stream); + return this.reconnect(stream, isLocal); + } + + // This is a reconnection timer for a peer that hasn't succeeded in the first + // place. Set reconnection timeouts with random intervals between them to try + // and reconnect without flooding the server + if (shouldSetReconnectionTimeout) { + const newReconnectTimer = this.restartTimer[stream] || CAMERA_SHARE_FAILED_WAIT_TIME; + this.restartTimer[stream] = newReconnectTimer; + + this.restartTimeout[stream] = setTimeout( + this._getWebRTCStartTimeout(stream, isLocal), + this.restartTimer[stream] + ); + } + } + + _getOnIceCandidateCallback(stream, isLocal) { + if (SIGNAL_CANDIDATES) { + return (candidate) => { + const peer = this.webRtcPeers[stream]; + const role = VideoService.getRole(isLocal); + + if (peer && !peer.didSDPAnswered) { + this.outboundIceQueues[stream].push(candidate); + return; + } + + this.sendIceCandidateToSFU(peer, role, candidate, stream); + }; + } + + return null; + } + + sendIceCandidateToSFU(peer, role, candidate, stream) { + const message = { + type: 'video', + role, + id: 'onIceCandidate', + candidate, + cameraId: stream, + }; + this.sendMessage(message); + } + + _handleLocalStreamInactive(stream) { + const peer = this.webRtcPeers[stream]; + const isLocal = VideoService.isLocalStream(stream); + const role = VideoService.getRole(isLocal); + + // Peer == null: this is a trailing event. + // !isLocal: someone is misusing this handler - local streams only. + if (peer == null || !isLocal) return; + + logger.error({ + logCode: 'video_provider_local_stream_inactive', + extraInfo: { + cameraId: stream, + role, + }, + }, 'Local camera stream stopped unexpectedly'); + + const error = new Error('inactiveError'); + this._onWebRTCError(error, stream, isLocal); + } + + _handleIceConnectionStateChange(stream, isLocal) { + const { intl } = this.props; + const peer = this.webRtcPeers[stream]; + const role = VideoService.getRole(isLocal); + + if (peer && peer.peerConnection) { + const pc = peer.peerConnection; + const connectionState = pc.connectionState; + notifyStreamStateChange(stream, connectionState); + + if (connectionState === 'failed' || connectionState === 'closed') { + const error = new Error('iceConnectionStateError'); + // prevent the same error from being detected multiple times + pc.onconnectionstatechange = null; + + logger.error({ + logCode: 'video_provider_ice_connection_failed_state', + extraInfo: { + cameraId: stream, + connectionState, + role, + }, + }, `Camera ICE connection state changed: ${connectionState}. Role: ${role}.`); + + this._onWebRTCError(error, stream, isLocal); + } + } else { + logger.error({ + logCode: 'video_provider_ice_connection_nopeer', + extraInfo: { cameraId: stream, role }, + }, `No peer at ICE connection state handler. Camera: ${stream}. Role: ${role}`); + } + } + + attach (peer, videoElement) { + if (peer && videoElement) { + const stream = peer.isPublisher ? peer.getLocalStream() : peer.getRemoteStream(); + videoElement.pause(); + videoElement.srcObject = stream; + videoElement.load(); + } + } + + getVideoElement(streamId) { + return this.videoTags[streamId]; + } + + attachVideoStream(stream) { + const videoElement = this.getVideoElement(stream); + const isLocal = VideoService.isLocalStream(stream); + const peer = this.webRtcPeers[stream]; + + if (VideoProviderGraphql.shouldAttachVideoStream(peer, videoElement)) { + const pc = peer.peerConnection; + // Notify current stream state again on attachment since the + // video-list-item component may not have been mounted before the stream + // reached the connected state. + // This is necessary to ensure that the video element is properly + // hidden/shown when the stream is attached. + notifyStreamStateChange(stream, pc.connectionState); + this.attach(peer, videoElement); + + if (isLocal) { + if (peer.bbbVideoStream == null) { + this.handleVirtualBgError(new TypeError('Undefined media stream')); + return; + } + + const deviceId = MediaStreamUtils.extractDeviceIdFromStream( + peer.bbbVideoStream.mediaStream, + 'video', + ); + const { type, name } = getSessionVirtualBackgroundInfo(deviceId); + + this.restoreVirtualBackground(peer.bbbVideoStream, type, name).catch((error) => { + this.handleVirtualBgError(error, type, name); + }); + } + } + } + + startVirtualBackgroundByDrop(stream, type, name, data) { + return new Promise((resolve, reject) => { + const peer = this.webRtcPeers[stream]; + const { bbbVideoStream } = peer; + const video = this.getVideoElement(stream); + + if (peer && video && video.srcObject) { + bbbVideoStream.startVirtualBackground(type, name, { file: data }) + .then(resolve) + .catch(reject); + } + }).catch((error) => { + this.handleVirtualBgErrorByDropping(error, type, name); + }); + } + + handleVirtualBgErrorByDropping(error, type, name) { + logger.error({ + logCode: `video_provider_virtualbg_error`, + extraInfo: { + errorName: error.name, + errorMessage: error.message, + virtualBgType: type, + virtualBgName: name, + }, + }, `Failed to start virtual background by dropping image: ${error.message}`); + } + + restoreVirtualBackground(stream, type, name) { + return new Promise((resolve, reject) => { + if (type !== EFFECT_TYPES.NONE_TYPE) { + stream.startVirtualBackground(type, name).then(() => { + resolve(); + }).catch((error) => { + reject(error); + }); + } + resolve(); + }); + } + + handleVirtualBgError(error, type, name) { + const { intl } = this.props; + logger.error({ + logCode: `video_provider_virtualbg_error`, + extraInfo: { + errorName: error.name, + errorMessage: error.message, + virtualBgType: type, + virtualBgName: name, + }, + }, `Failed to restore virtual background after reentering the room: ${error.message}`); + + notify(intl.formatMessage(intlClientErrors.virtualBgGenericError), 'error', 'video'); + } + + createVideoTag(stream, video) { + const peer = this.webRtcPeers[stream]; + this.videoTags[stream] = video; + + if (peer && peer.stream === stream) { + this.attachVideoStream(stream); + } + } + + destroyVideoTag(stream) { + const videoElement = this.videoTags[stream]; + + if (videoElement == null) return; + + if (typeof videoElement.pause === 'function') { + videoElement.pause(); + videoElement.srcObject = null; + } + + delete this.videoTags[stream]; + } + + handlePlayStop(message) { + const { intl } = this.props; + const { cameraId: stream, role } = message; + + logger.info({ + logCode: 'video_provider_handle_play_stop', + extraInfo: { + cameraId: stream, + role, + }, + }, `Received request from SFU to stop camera. Role: ${role}`); + + VideoService.notify(intl.formatMessage(intlClientErrors.mediaTimedOutError)); + this.stopWebRTCPeer(stream, false); + } + + handlePlayStart(message) { + const { cameraId: stream, role } = message; + const peer = this.webRtcPeers[stream]; + const { playStart } = this.props; + + if (peer) { + logger.info({ + logCode: 'video_provider_handle_play_start_flowing', + extraInfo: { + cameraId: stream, + role, + }, + }, `Camera media is flowing (server). Role: ${role}`); + + peer.started = true; + + // Clear camera shared timeout when camera successfully starts + this.clearRestartTimers(stream); + this.attachVideoStream(stream); + + playStart(stream); + } else { + logger.warn({ + logCode: 'video_provider_playstart_no_peer', + extraInfo: { cameraId: stream, role }, + }, 'Trailing camera playStart response.'); + } + } + + handleSFUError(message) { + const { intl, streams, sendUserUnshareWebcam } = this.props; + const { code, reason, streamId } = message; + const isLocal = VideoService.isLocalStream(streamId); + const role = VideoService.getRole(isLocal); + + logger.error({ + logCode: 'video_provider_handle_sfu_error', + extraInfo: { + errorCode: code, + errorReason: reason, + cameraId: streamId, + role, + }, + }, `SFU returned an error. Code: ${code}, reason: ${reason}`); + + if (isLocal) { + // The publisher instance received an error from the server. There's no reconnect, + // stop it. + VideoService.notify(intl.formatMessage(intlSFUErrors[code] || intlSFUErrors[2200])); + VideoService.stopVideo(streamId, sendUserUnshareWebcam, streams); + } else { + const peer = this.webRtcPeers[streamId]; + const stillExists = streams.some(({ stream }) => streamId === stream); + + if (stillExists) { + const isEstablishedConnection = peer && peer.started; + this.setReconnectionTimeout(streamId, isLocal, isEstablishedConnection); + } + + this.stopWebRTCPeer(streamId, stillExists); + } + } + + replacePCVideoTracks(streamId, mediaStream) { + const peer = this.webRtcPeers[streamId]; + const videoElement = this.getVideoElement(streamId); + + if (peer == null || mediaStream == null || videoElement == null) return; + + const pc = peer.peerConnection; + const newTracks = mediaStream.getVideoTracks(); + + if (pc) { + const trackReplacers = pc.getSenders().map(async (sender, index) => { + if (sender.track == null || sender.track.kind !== 'video') return false; + const newTrack = newTracks[index]; + if (newTrack == null) return false; + try { + await sender.replaceTrack(newTrack); + return true; + } catch (error) { + logger.warn({ + logCode: 'video_provider_replacepc_error', + extraInfo: { errorMessage: error.message, cameraId: streamId }, + }, `Failed to replace peer connection tracks: ${error.message}`); + return false; + } + }); + Promise.all(trackReplacers).then(() => { + this.attach(peer, videoElement); + }); + } + } + + render() { + const { + swapLayout, + currentVideoPageIndex, + streams, + cameraDockBounds, + focusedId, + handleVideoFocus, + isGridEnabled, + users, + } = this.props; + + return ( + + ); + } +} + +VideoProviderGraphql.propTypes = propTypes; + +export default injectIntl(VideoProviderGraphql); diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/container.tsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/container.tsx new file mode 100755 index 0000000000..8eb2bdc182 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/container.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { Session } from 'meteor/session'; +import { Meteor } from 'meteor/meteor'; +import { withTracker } from 'meteor/react-meteor-data'; +import { useMutation } from '@apollo/client'; +import useMeeting from '/imports/ui/core/hooks/useMeeting'; +import Auth from '/imports/ui/services/auth'; +import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import Settings from '/imports/ui/services/settings'; +import { + useCurrentVideoPageIndex, + useExitVideo, + useIsUserLocked, + useVideoStreams, +} from './hooks'; +import { CAMERA_BROADCAST_START, CAMERA_BROADCAST_STOP } from './mutations'; +import VideoProvider from './component'; +import VideoService from './service'; + +interface VideoProviderContainerGraphqlProps { + isGridLayout: boolean; + currUserId: string; + paginationEnabled: boolean; + viewParticipantsWebcams: boolean; + children: React.ReactNode; +} + +const VideoProviderContainerGraphql: React.FC = ({ children, ...props }) => { + const { + isGridLayout, + currUserId, + paginationEnabled, + viewParticipantsWebcams, + } = props; + const [cameraBroadcastStart] = useMutation(CAMERA_BROADCAST_START); + const [cameraBroadcastStop] = useMutation(CAMERA_BROADCAST_STOP); + + const sendUserShareWebcam = (cameraId: string) => { + return cameraBroadcastStart({ variables: { cameraId } }); + }; + + const sendUserUnshareWebcam = (cameraId: string) => { + cameraBroadcastStop({ variables: { cameraId } }); + }; + + const playStart = (cameraId: string) => { + if (VideoService.isLocalStream(cameraId)) { + sendUserShareWebcam(cameraId).then(() => { + setTimeout(() => { + VideoService.joinedVideo(); + }, 500); + }); + } + }; + + const { data: currentMeeting } = useMeeting((m) => ({ + usersPolicies: m.usersPolicies, + })); + + const { data: currentUser } = useCurrentUser((user) => ({ + locked: user.locked, + })); + + const { + streams, + gridUsers, + totalNumberOfStreams, + users, + } = useVideoStreams(isGridLayout, paginationEnabled, viewParticipantsWebcams); + + let usersVideo = streams; + + if (gridUsers.length > 0 && isGridLayout) { + usersVideo = usersVideo.concat(gridUsers); + } + + if ( + currentMeeting?.usersPolicies?.webcamsOnlyForModerator + && currentUser?.locked + ) { + usersVideo = usersVideo.filter((uv) => uv.isUserModerator || uv.userId === currUserId); + } + + const isUserLocked = useIsUserLocked(); + const currentVideoPageIndex = useCurrentVideoPageIndex(); + const exitVideo = useExitVideo(); + + return ( + !usersVideo.length && !isGridLayout + ? null + : ( + + {children} + + ) + ); +}; + +export default withTracker(() => { + const isGridLayout = Session.get('isGridEnabled'); + const currUserId = Auth.userID; + const isMeteorConnected = Meteor.status().connected; + + return { + currUserId, + isGridLayout, + isMeteorConnected, + paginationEnabled: Settings.application.paginationEnabled, + viewParticipantsWebcams: Settings.dataSaving.viewParticipantsWebcams, + }; +})(VideoProviderContainerGraphql); diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/hooks/index.ts b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/hooks/index.ts new file mode 100644 index 0000000000..be8cc913b6 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/hooks/index.ts @@ -0,0 +1,615 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useSubscription, useReactiveVar, useLazyQuery, useMutation } from '@apollo/client'; +import { Meteor } from 'meteor/meteor'; +import Settings from '/imports/ui/services/settings'; +import Auth from '/imports/ui/services/auth'; +import Users from '/imports/api/users'; +import getFromUserSettings from '/imports/ui/services/users-settings'; +import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import useMeeting from '/imports/ui/core/hooks/useMeeting'; +import { partition } from '/imports/utils/array-utils'; +import { USER_AGGREGATE_COUNT_SUBSCRIPTION } from '/imports/ui/core/graphql/queries/users'; +import { + getSortingMethod, + sortVideoStreams, +} from '/imports/ui/components/video-provider/stream-sorting'; +import { + useVideoState, + setVideoState, + useConnectingStream, + streams, + getVideoState, +} from '../state'; +import { + OWN_VIDEO_STREAMS_QUERY, + VIDEO_STREAMS_USERS_SUBSCRIPTION, + VIEWERS_IN_WEBCAM_COUNT_SUBSCRIPTION, + VideoStreamsUsersResponse, +} from '../queries'; +import videoService from '../service'; +import { CAMERA_BROADCAST_STOP } from '../mutations'; + +const ROLE_MODERATOR = window.meetingClientSettings.public.user.role_moderator; +const ROLE_VIEWER = window.meetingClientSettings.public.user.role_viewer; +const MIRROR_WEBCAM = window.meetingClientSettings.public.app.mirrorOwnWebcam; +const { + paginationToggleEnabled: PAGINATION_TOGGLE_ENABLED, + desktopPageSizes: DESKTOP_PAGE_SIZES, + mobilePageSizes: MOBILE_PAGE_SIZES, + desktopGridSizes: DESKTOP_GRID_SIZES, + mobileGridSizes: MOBILE_GRID_SIZES, +} = window.meetingClientSettings.public.kurento.pagination; +const PAGINATION_THRESHOLDS_CONF = window.meetingClientSettings.public.kurento.paginationThresholds; +const PAGINATION_THRESHOLDS = PAGINATION_THRESHOLDS_CONF.thresholds.sort( + (t1, t2) => t1.users - t2.users, +); +const PAGINATION_THRESHOLDS_ENABLED = PAGINATION_THRESHOLDS_CONF.enabled; +const { + paginationSorting: PAGINATION_SORTING, + defaultSorting: DEFAULT_SORTING, +} = window.meetingClientSettings.public.kurento.cameraSortingModes; + +export const useFetchedVideoStreams = () => { + const { streams: s } = useStreams(); + let streams = [...s]; + const connectingStream = useConnectingStream(streams); + const isPaginationEnabled = useIsPaginationEnabled(); + const isPaginationDisabled = !isPaginationEnabled; + + const { viewParticipantsWebcams } = Settings.dataSaving; + if (!viewParticipantsWebcams) streams = videoService.filterLocalOnly(streams); + + if (connectingStream) { + streams.push(connectingStream); + } + const pages = useVideoPage(streams); + + if (!isPaginationDisabled) { + return pages; + } + + return streams; +}; + +export const useStatus = () => { + const videoState = useVideoState()[0]; + if (videoState.isConnecting) return 'videoConnecting'; + if (videoState.isConnected) return 'connected'; + return 'disconnected'; +}; + +export const useDisableReason = () => { + const videoLocked = useIsUserLocked(); + const hasCapReached = useHasCapReached(); + const hasVideoStream = useHasVideoStream(); + const locks = { + videoLocked, + camCapReached: hasCapReached && !hasVideoStream, + meteorDisconnected: !Meteor.status().connected, + }; + const locksKeys = Object.keys(locks); + const disableReason = locksKeys + .filter((i) => locks[i as keyof typeof locks]) + .shift(); + return disableReason; +}; + +export const useRole = (isLocal: boolean) => { + return isLocal ? 'share' : 'viewer'; +}; + +export const useMyStreamId = (deviceId: string) => { + const { streams } = useStreams(); + const videoStream = streams.find( + (vs) => vs.userId === Auth.userID && vs.deviceId === deviceId, + ); + return videoStream ? videoStream.stream : null; +}; + +export const useIsUserLocked = () => { + const disableCam = useDisableCam(); + const { data: currentUser } = useCurrentUser((u) => ({ + locked: u.locked, + isModerator: u.isModerator, + })); + return currentUser?.locked && !currentUser.isModerator && disableCam; +}; + +export const useVideoStreamsCount = () => { + const { streams } = useStreams(); + + return streams.length; +}; + +export const useLocalVideoStreamsCount = () => { + const { streams } = useStreams(); + const localStreams = streams.filter((vs) => vs.userId === Auth.userID); + + return localStreams.length; +}; + +export const useInfo = () => { + const { data } = useMeeting((m) => ({ + voiceSettings: { + voiceConf: m.voiceSettings?.voiceConf, + }, + })); + const voiceBridge = data?.voiceSettings ? data.voiceSettings.voiceConf : null; + return { + userId: Auth.userID, + userName: Auth.fullname, + meetingId: Auth.meetingID, + sessionToken: Auth.sessionToken, + voiceBridge, + }; +}; + +export const useMirrorOwnWebcam = (userId = null) => { + // only true if setting defined and video ids match + const isOwnWebcam = userId ? Auth.userID === userId : true; + const isEnabledMirroring = getFromUserSettings( + 'bbb_mirror_own_webcam', + MIRROR_WEBCAM, + ); + return isOwnWebcam && isEnabledMirroring; +}; + +export const useHasCapReached = () => { + const { data: meeting } = useMeeting((m) => ({ + meetingCameraCap: m.meetingCameraCap, + usersPolicies: { + userCameraCap: m.usersPolicies?.userCameraCap, + }, + })); + const videoStreamsCount = useVideoStreamsCount(); + const localVideoStreamsCount = useLocalVideoStreamsCount(); + + // If the meeting prop data is unreachable, force a safe return + if ( + meeting?.usersPolicies === undefined + || !meeting?.meetingCameraCap === undefined + ) return true; + const { meetingCameraCap } = meeting; + const { userCameraCap } = meeting.usersPolicies; + + const meetingCap = meetingCameraCap !== 0 && videoStreamsCount >= meetingCameraCap; + const userCap = userCameraCap !== 0 && localVideoStreamsCount >= userCameraCap; + + return meetingCap || userCap; +}; + +export const useWebcamsOnlyForModerator = () => { + const { data: meeting } = useMeeting((m) => ({ + usersPolicies: { + webcamsOnlyForModerator: m.usersPolicies?.webcamsOnlyForModerator, + }, + })); + const user = Users.findOne( + { userId: Auth.userID }, + { fields: { locked: 1, role: 1 } }, + ); + + if (meeting?.usersPolicies && user?.role !== ROLE_MODERATOR && user?.locked) { + return meeting.usersPolicies.webcamsOnlyForModerator; + } + return false; +}; + +export const useDisableCam = () => { + const { data: meeting } = useMeeting((m) => ({ + lockSettings: { + disableCam: m.lockSettings?.disableCam, + }, + })); + return meeting?.lockSettings ? meeting?.lockSettings.disableCam : false; +}; + +export const useVideoPinByUser = (userId: string) => { + const user = Users.findOne({ userId }, { fields: { pin: 1 } }); + + return user?.pin || false; +}; + +export const useSetNumberOfPages = ( + numberOfPublishers: number, + numberOfSubscribers: number, + pageSize: number, +) => { + let { currentVideoPageIndex, numberOfPages } = useVideoState()[0]; + + useEffect(() => { + // Page size 0 means no pagination, return itself + if (pageSize === 0) return; + + // Page size refers only to the number of subscribers. Publishers are always + // shown, hence not accounted for + const nOfPages = Math.ceil(numberOfSubscribers / pageSize); + + if (nOfPages !== numberOfPages) { + numberOfPages = nOfPages; + // Check if we have to page back on the current video page index due to a + // page ceasing to exist + if (nOfPages === 0) { + currentVideoPageIndex = 0; + } else if (currentVideoPageIndex + 1 > numberOfPages) { + videoService.getPreviousVideoPage(); + } + + videoService.numberOfPages = nOfPages; + videoService.currentVideoPageIndex = currentVideoPageIndex; + setVideoState((curr) => ({ + ...curr, + numberOfPages, + currentVideoPageIndex, + })); + } + }, [numberOfPublishers, numberOfSubscribers, pageSize, currentVideoPageIndex, numberOfPages]); + + return null; +}; + +export const usePageSizeDictionary = () => { + const { data: countData } = useSubscription( + USER_AGGREGATE_COUNT_SUBSCRIPTION, + ); + const userCount = countData?.user_aggregate?.aggregate?.count || 0; + // Dynamic page sizes are disabled. Fetch the stock page sizes. + if (!PAGINATION_THRESHOLDS_ENABLED || PAGINATION_THRESHOLDS.length <= 0) { + return !videoService.isMobile ? DESKTOP_PAGE_SIZES : MOBILE_PAGE_SIZES; + } + + // Dynamic page sizes are enabled. Get the user count, isolate the + // matching threshold entry, return the val. + let targetThreshold; + const processThreshold = ( + threshold = { + desktopPageSizes: DESKTOP_PAGE_SIZES, + mobilePageSizes: MOBILE_PAGE_SIZES, + }, + ) => { + // We don't demand that all page sizes should be set in pagination profiles. + // That saves us some space because don't necessarily need to scale mobile + // endpoints. + // If eg mobile isn't set, then return the default value. + if (!videoService.isMobile) { + return threshold.desktopPageSizes || DESKTOP_PAGE_SIZES; + } + return threshold.mobilePageSizes || MOBILE_PAGE_SIZES; + }; + + // Short-circuit: no threshold yet, return stock values (processThreshold has a default arg) + if (userCount < PAGINATION_THRESHOLDS[0].users) return processThreshold(); + + // Reverse search for the threshold where our participant count is directly equal or great + // The PAGINATION_THRESHOLDS config is sorted when imported. + for ( + let mapIndex = PAGINATION_THRESHOLDS.length - 1; + mapIndex >= 0; + mapIndex -= 1 + ) { + targetThreshold = PAGINATION_THRESHOLDS[mapIndex]; + if (targetThreshold.users <= userCount) { + return processThreshold(targetThreshold); + } + } + return undefined; +}; + +export const useMyRole = () => { + const { data } = useCurrentUser((u) => ({ role: u.role })); + return data?.role; +}; + +export const useMyPageSize = () => { + const myRole = useMyRole(); + const pageSizes = usePageSizeDictionary(); + let size; + switch (myRole) { + case ROLE_MODERATOR: + size = pageSizes.moderator; + break; + case ROLE_VIEWER: + default: + size = pageSizes.viewer; + } + + return size; +}; + +export const useShouldRenderPaginationToggle = () => PAGINATION_TOGGLE_ENABLED && useMyPageSize() > 0; + +export const useIsPaginationEnabled = (paginationEnabled) => paginationEnabled && useMyPageSize() > 0; + +export const useStreams = () => { + const videoStreams = useReactiveVar(streams); + return { streams: videoStreams }; +}; + +export const useStreamUsers = () => { + const { data, loading, error } = useSubscription( + VIDEO_STREAMS_USERS_SUBSCRIPTION, + ); + const users = useMemo( + () => (data + ? data.user.map((user) => ({ + ...user, + pin: user.pinned, + sortName: user.nameSortable, + })) + : []), + [data], + ); + + return { + users: users || [], + loading, + error, + }; +}; + +export const useSharedDevices = () => { + const { streams } = useStreams(); + const devices = streams + .filter((s) => s.userId === Auth.userID) + .map((vs) => vs.deviceId); + + return devices; +}; + +export const useUserIdsFromVideoStreams = () => { + const { streams } = useStreams(); + return streams.map((s) => s.userId); +}; + +export const useNumberOfPages = () => { + const state = useVideoState()[0]; + return state.numberOfPages; +}; + +export const useCurrentVideoPageIndex = () => { + const state = useVideoState()[0]; + return state.currentVideoPageIndex; +}; + +export const useGridSize = () => { + let size; + const myRole = useMyRole(); + const gridSizes = !videoService.isMobile + ? DESKTOP_GRID_SIZES + : MOBILE_GRID_SIZES; + + switch (myRole) { + case ROLE_MODERATOR: + size = gridSizes.moderator; + break; + case ROLE_VIEWER: + default: + size = gridSizes.viewer; + } + + return size; +}; + +export const useVideoPage = (streams) => { + const numberOfPages = useNumberOfPages(); + const currentVideoPageIndex = useCurrentVideoPageIndex(); + const pageSize = useMyPageSize(); + + // Publishers are taken into account for the page size calculations. They + // also appear on every page. Same for pinned user. + const [filtered, others] = partition( + streams, + (vs) => Auth.userID === vs.userId || vs.pin, + ); + + // Separate pin from local cameras + const [pin, mine] = partition(filtered, (vs) => vs.pin); + + // Recalculate total number of pages + useSetNumberOfPages(filtered.length, others.length, pageSize); + const chunkIndex = currentVideoPageIndex * pageSize; + + // This is an extra check because pagination is globally in effect (hard + // limited page sizes, toggles on), but we might still only have one page. + // Use the default sorting method if that's the case. + const sortingMethod = numberOfPages > 1 ? PAGINATION_SORTING : DEFAULT_SORTING; + const paginatedStreams = sortVideoStreams(others, sortingMethod).slice( + chunkIndex, + chunkIndex + pageSize, + ) || []; + + if (getSortingMethod(sortingMethod).localFirst) { + return [...pin, ...mine, ...paginatedStreams]; + } + return [...pin, ...paginatedStreams, ...mine]; +}; + +export const useVideoStreams = ( + isGridEnabled: boolean, + paginationEnabled: boolean, + viewParticipantsWebcams: boolean, +) => { + const [state] = useVideoState(); + const { currentVideoPageIndex, numberOfPages } = state; + const { users } = useStreamUsers(); + const { streams: videoStreams} = useStreams(); + const connectingStream = useConnectingStream(videoStreams); + const gridSize = useGridSize(); + const myPageSize = useMyPageSize(); + const isPaginationEnabled = useIsPaginationEnabled(paginationEnabled); + let streams = [...videoStreams]; + let gridUsers = []; + + if (connectingStream) streams.push(connectingStream); + + if (!viewParticipantsWebcams) { + streams = streams.filter((stream) => stream.userId === Auth.userID); + } + + if (isPaginationEnabled) { + const [filtered, others] = partition(streams, (vs) => Auth.userID === vs.userId || vs.pin); + const [pin, mine] = partition(filtered, (vs) => vs.pin); + + if (myPageSize !== 0) { + const total = others.length ?? 0; + const nOfPages = Math.ceil(total / myPageSize); + + if (nOfPages !== numberOfPages) { + setVideoState((curr) => ({ + ...curr, + numberOfPages: nOfPages, + })); + + if (nOfPages === 0) { + setVideoState((curr) => ({ + ...curr, + currentVideoPageIndex: 0, + })); + } else if (currentVideoPageIndex + 1 > nOfPages) { + videoService.getPreviousVideoPage(); + } + } + } + + const chunkIndex = currentVideoPageIndex * myPageSize; + + const sortingMethod = (numberOfPages > 1) ? PAGINATION_SORTING : DEFAULT_SORTING; + const paginatedStreams = sortVideoStreams(others, sortingMethod) + .slice(chunkIndex, (chunkIndex + myPageSize)) || []; + + if (getSortingMethod(sortingMethod).localFirst) { + streams = [...pin, ...mine, ...paginatedStreams]; + } else { + streams = [...pin, ...paginatedStreams, ...mine]; + } + } else { + streams = sortVideoStreams(streams, DEFAULT_SORTING); + } + + if (isGridEnabled) { + const streamUsers = streams.map((stream) => stream.userId); + + gridUsers = users + .filter( + (user) => !user.loggedOut && !user.left && !streamUsers.includes(user.userId), + ) + .map((user) => ({ + isGridItem: true, + ...user, + })) + .slice(0, gridSize - streams.length); + } + + return { + streams, + gridUsers, + totalNumberOfStreams: streams.length, + users, + }; +}; + +export const useGridUsers = (users = []) => { + const pageSize = useMyPageSize(); + const { streams } = useStreams(); + const paginatedStreams = useVideoPage(streams, pageSize); + const isPaginationEnabled = useIsPaginationEnabled(); + const isPaginationDisabled = !isPaginationEnabled || pageSize === 0; + const isGridEnabled = videoService.isGridEnabled(); + let gridUsers = []; + + if (isPaginationDisabled) { + if (isGridEnabled) { + const streamUsers = streams.map((stream) => stream.userId); + + gridUsers = users + .filter( + (user) => !user.loggedOut && !user.left && !streamUsers.includes(user.userId), + ) + .map((user) => ({ + isGridItem: true, + ...user, + })); + } + + return gridUsers; + } + + if (isGridEnabled) { + const streamUsers = paginatedStreams.map((stream) => stream.userId); + + gridUsers = users + .filter( + (user) => !user.loggedOut && !user.left && !streamUsers.includes(user.userId), + ) + .map((user) => ({ + isGridItem: true, + ...user, + })); + } + return gridUsers; +}; + +export const useHasVideoStream = () => { + const { streams } = useStreams(); + return streams.some((s) => s.userId === Auth.userID); +}; + +export const useHasStream = (streams, stream) => { + return streams.find((s) => s.stream === stream); +}; + +export const useFilterModeratorOnly = (streams) => { + const amIViewer = useMyRole() === ROLE_VIEWER; + + if (amIViewer) { + const moderators = Users.find( + { + role: ROLE_MODERATOR, + }, + { fields: { userId: 1 } }, + ) + .fetch() + .map((user) => user.userId); + + return streams.reduce((result, stream) => { + const { userId } = stream; + + const isModerator = moderators.includes(userId); + const isMe = Auth.userID === userId; + + if (isModerator || isMe) result.push(stream); + + return result; + }, []); + } + return streams; +}; + +export const useExitVideo = () => { + const [cameraBroadcastStop] = useMutation(CAMERA_BROADCAST_STOP); + const [getOwnVideoStreams] = useLazyQuery(OWN_VIDEO_STREAMS_QUERY, { variables: { userId: Auth.userID } }); + + const exitVideo = useCallback(() => { + const { isConnected } = getVideoState(); + + if (isConnected) { + const sendUserUnshareWebcam = (cameraId: string) => { + cameraBroadcastStop({ variables: { cameraId } }); + }; + + getOwnVideoStreams().then(({ data }) => { + if (!data) return; + const streams = data.user_camera || []; + streams.forEach((s) => sendUserUnshareWebcam(s.streamId)); + videoService.exitedVideo(); + }); + } + }, [cameraBroadcastStop]); + + return exitVideo; +}; + +export const useViewersInWebcamCount = () => { + const { data } = useSubscription(VIEWERS_IN_WEBCAM_COUNT_SUBSCRIPTION); + return data?.user_camera_aggregate?.aggregate?.count || 0; +}; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/many-users-notify/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/many-users-notify/component.jsx new file mode 100644 index 0000000000..251cc3283c --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/many-users-notify/component.jsx @@ -0,0 +1,101 @@ +import React, { Component } from 'react'; +import { defineMessages, injectIntl } from 'react-intl'; +import { notify } from '/imports/ui/services/notification'; +import { toast } from 'react-toastify'; +import Styled from './styles'; + +const intlMessages = defineMessages({ + suggestLockTitle: { + id: 'app.video.suggestWebcamLock', + description: 'Label for notification title', + }, + suggestLockReason: { + id: 'app.video.suggestWebcamLockReason', + description: 'Reason for activate the webcams\'s lock', + }, + enable: { + id: 'app.video.enable', + description: 'Enable button label', + }, + cancel: { + id: 'app.video.cancel', + description: 'Cancel button label', + }, +}); + +const REPEAT_INTERVAL = 120000; + +class LockViewersNotifyComponent extends Component { + constructor(props) { + super(props); + this.interval = null; + this.intervalCallback = this.intervalCallback.bind(this); + } + + componentDidUpdate() { + const { + viewersInWebcam, + lockSettings, + limitOfViewersInWebcam, + webcamOnlyForModerator, + currentUserIsModerator, + limitOfViewersInWebcamIsEnable, + } = this.props; + const viwerersInWebcamGreaterThatLimit = (viewersInWebcam >= limitOfViewersInWebcam) + && limitOfViewersInWebcamIsEnable; + const webcamForViewersIsLocked = lockSettings.disableCam || webcamOnlyForModerator; + + if (viwerersInWebcamGreaterThatLimit + && !webcamForViewersIsLocked + && currentUserIsModerator + && !this.interval) { + this.interval = setInterval(this.intervalCallback, REPEAT_INTERVAL); + this.intervalCallback(); + } + if (webcamForViewersIsLocked || (!viwerersInWebcamGreaterThatLimit && this.interval)) { + clearInterval(this.interval); + this.interval = null; + } + } + + intervalCallback() { + const { + toggleWebcamsOnlyForModerator, + intl, + } = this.props; + const lockToastId = `suggestLock-${new Date().getTime()}`; + + notify( + ( + <> + {intl.formatMessage(intlMessages.suggestLockTitle)} + + + | + toast.dismiss(lockToastId)} + /> + + {intl.formatMessage(intlMessages.suggestLockReason)} + + ), + 'info', + 'rooms', + { + toastId: lockToastId, + }, + ); + } + + render() { + return null; + } +} + +export default injectIntl(LockViewersNotifyComponent); diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/many-users-notify/container.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/many-users-notify/container.jsx new file mode 100644 index 0000000000..bd098a2f89 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/many-users-notify/container.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import Meetings from '/imports/api/meetings'; +import Auth from '/imports/ui/services/auth'; +import { useMutation } from '@apollo/client'; +import ManyUsersComponent from './component'; +import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import { SET_WEBCAM_ONLY_FOR_MODERATOR } from '/imports/ui/components/lock-viewers/mutations'; +import { useViewersInWebcamCount } from '../hooks'; + +const ManyUsersContainer = (props) => { + const { data: currentUserData } = useCurrentUser((user) => ({ + isModerator: user.isModerator, + })); + + const [setWebcamOnlyForModerator] = useMutation(SET_WEBCAM_ONLY_FOR_MODERATOR); + + const toggleWebcamsOnlyForModerator = () => { + setWebcamOnlyForModerator({ + variables: { + webcamsOnlyForModerator: true, + }, + }); + }; + + const viewersInWebcam = useViewersInWebcamCount(); + + const currentUserIsModerator = currentUserData?.isModerator; + return ( + + ); +}; + +export default withTracker(() => { + const meeting = Meetings.findOne({ + meetingId: Auth.meetingID, + }, { fields: { 'usersPolicies.webcamsOnlyForModerator': 1, lockSettings: 1 } }); + return { + lockSettings: meeting.lockSettings, + webcamOnlyForModerator: meeting.usersPolicies.webcamsOnlyForModerator, + limitOfViewersInWebcam: window.meetingClientSettings.public.app.viewersInWebcam, + limitOfViewersInWebcamIsEnable: window.meetingClientSettings + .public.app.enableLimitOfViewersInWebcam, + }; +})(ManyUsersContainer); diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/many-users-notify/styles.js b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/many-users-notify/styles.js new file mode 100644 index 0000000000..64af0dfafd --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/many-users-notify/styles.js @@ -0,0 +1,35 @@ +import styled from 'styled-components'; +import { colorPrimary } from '/imports/ui/stylesheets/styled-components/palette'; +import Styled from '/imports/ui/components/breakout-room/styles'; +import Button from '/imports/ui/components/common/button/component'; + +const Info = styled.p` + margin: 0; +`; + +const ButtonWrapper = styled(Styled.BreakoutActions)` + background-color: inherit; + + &:focus,&:hover { + background-color: inherit; + } +`; + +const ManyUsersButton = styled(Button)` + flex: 0 1 48%; + color: ${colorPrimary}; + margin: 0; + font-weight: inherit; + + background-color: inherit; + + &:focus,&:hover { + background-color: inherit; + } +`; + +export default { + Info, + ButtonWrapper, + ManyUsersButton, +}; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/mutations.ts b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/mutations.ts new file mode 100644 index 0000000000..5c7a20eda7 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/mutations.ts @@ -0,0 +1,22 @@ +import { gql } from '@apollo/client'; + +export const CAMERA_BROADCAST_START = gql` + mutation CameraBroadcastStart($cameraId: String!) { + cameraBroadcastStart( + stream: $cameraId + ) + } +`; + +export const CAMERA_BROADCAST_STOP = gql` + mutation CameraBroadcastStop($cameraId: String!) { + cameraBroadcastStop( + stream: $cameraId + ) + } +`; + +export default { + CAMERA_BROADCAST_START, + CAMERA_BROADCAST_STOP, +}; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/queries.ts b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/queries.ts new file mode 100644 index 0000000000..02d956a96e --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/queries.ts @@ -0,0 +1,116 @@ +import { gql } from '@apollo/client'; + +export interface VideoStreamsResponse { + user_camera: { + streamId: string; + user: { + userId: string; + pinned: boolean; + nameSortable: string; + name: string; + isModerator: boolean; + }; + voice?: { + floor: boolean; + lastFloorTime: string; + }; + }[]; +} + +export interface VideoStreamsUsersResponse { + user: { + userId: string; + pinned: boolean; + nameSortable: string; + name: string; + loggedOut: boolean; + away: boolean; + disconnected: boolean; + emoji: string; + role: string; + avatar: string; + color: string; + presenter: boolean; + clientType: string; + raiseHand: boolean; + isModerator: boolean + reaction: { + reactionEmoji: string; + }; + }[]; +} + +export const VIDEO_STREAMS_SUBSCRIPTION = gql` + subscription VideoStreams { + user_camera { + streamId + user { + userId + pinned + nameSortable + name + isModerator + } + voice { + floor + lastFloorTime + } + } + } +`; + +export const OWN_VIDEO_STREAMS_QUERY = gql` + query OwnVideoStreams($userId: String!) { + user_camera( + where: { + userId: { _eq: $userId } + }, + ) { + streamId + } + } +`; + +export const VIEWERS_IN_WEBCAM_COUNT_SUBSCRIPTION = gql` + subscription ViewerVideoStreams { + user_camera_aggregate(where: { + user: { role: { _eq: "VIEWER" }, presenter: { _eq: false } } + }) { + aggregate { + count + } + } + } +`; + +export const VIDEO_STREAMS_USERS_SUBSCRIPTION = gql` + subscription VideoStreamsUsers { + user { + name + userId + nameSortable + pinned + loggedOut + away + disconnected + emoji + role + avatar + color + presenter + clientType + userId + raiseHand + isModerator + reaction { + reactionEmoji + } + } + } +`; + +export default { + VIDEO_STREAMS_SUBSCRIPTION, + VIEWERS_IN_WEBCAM_COUNT_SUBSCRIPTION, + VIDEO_STREAMS_USERS_SUBSCRIPTION, +}; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/service.ts b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/service.ts new file mode 100755 index 0000000000..de33161e45 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/service.ts @@ -0,0 +1,1118 @@ +/* eslint-disable */ +// @ts-nocheck +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { Session } from 'meteor/session'; +import Settings from '/imports/ui/services/settings'; +import Auth from '/imports/ui/services/auth'; +import Meetings from '/imports/api/meetings'; +import Users from '/imports/api/users'; +import UserListService from '/imports/ui/components/user-list/service'; +import { meetingIsBreakout } from '/imports/ui/components/app/service'; +import { makeCall } from '/imports/ui/services/api'; +import { notify } from '/imports/ui/services/notification'; +import deviceInfo from '/imports/utils/deviceInfo'; +import browserInfo from '/imports/utils/browserInfo'; +import getFromUserSettings from '/imports/ui/services/users-settings'; +import VideoPreviewService from '/imports/ui/components/video-preview/service'; +import Storage from '/imports/ui/services/storage/session'; +import BBBStorage from '/imports/ui/services/storage'; +import logger from '/imports/startup/client/logger'; +import { debounce } from '/imports/utils/debounce'; +import { partition } from '/imports/utils/array-utils'; +import { + getSortingMethod, + sortVideoStreams, +} from '/imports/ui/components/video-provider/stream-sorting'; +import getFromMeetingSettings from '/imports/ui/services/meeting-settings'; +import { setVideoState, setConnectingStream, getVideoState } from './state'; + +const CAMERA_PROFILES = window.meetingClientSettings.public.kurento.cameraProfiles; +const MULTIPLE_CAMERAS = window.meetingClientSettings.public.app.enableMultipleCameras; + +const SFU_URL = window.meetingClientSettings.public.kurento.wsUrl; +const ROLE_MODERATOR = window.meetingClientSettings.public.user.role_moderator; +const ROLE_VIEWER = window.meetingClientSettings.public.user.role_viewer; +const MIRROR_WEBCAM = window.meetingClientSettings.public.app.mirrorOwnWebcam; +const PIN_WEBCAM = window.meetingClientSettings.public.kurento.enableVideoPin; +const { + thresholds: CAMERA_QUALITY_THRESHOLDS = [], + applyConstraints: CAMERA_QUALITY_THR_CONSTRAINTS = false, + debounceTime: CAMERA_QUALITY_THR_DEBOUNCE = 2500, +} = window.meetingClientSettings.public.kurento.cameraQualityThresholds; +const { + paginationToggleEnabled: PAGINATION_TOGGLE_ENABLED, + pageChangeDebounceTime: PAGE_CHANGE_DEBOUNCE_TIME, + desktopPageSizes: DESKTOP_PAGE_SIZES, + mobilePageSizes: MOBILE_PAGE_SIZES, + desktopGridSizes: DESKTOP_GRID_SIZES, + mobileGridSizes: MOBILE_GRID_SIZES, +} = window.meetingClientSettings.public.kurento.pagination; +const PAGINATION_THRESHOLDS_CONF = window.meetingClientSettings.public.kurento.paginationThresholds; +const PAGINATION_THRESHOLDS = PAGINATION_THRESHOLDS_CONF.thresholds.sort((t1, t2) => t1.users - t2.users); +const PAGINATION_THRESHOLDS_ENABLED = PAGINATION_THRESHOLDS_CONF.enabled; +const { + paginationSorting: PAGINATION_SORTING, + defaultSorting: DEFAULT_SORTING, +} = window.meetingClientSettings.public.kurento.cameraSortingModes; +const DEFAULT_VIDEO_MEDIA_SERVER = window.meetingClientSettings.public.kurento.videoMediaServer; + +const FILTER_VIDEO_STATS = [ + 'outbound-rtp', + 'inbound-rtp', +]; + +const TOKEN = '_'; + +class VideoService { + constructor() { + this.defineProperties({ + isConnecting: false, + isConnected: false, + currentVideoPageIndex: 0, + numberOfPages: 0, + pageSize: 0, + }); + this.userParameterProfile = null; + + this.isMobile = deviceInfo.isMobile; + this.isSafari = browserInfo.isSafari; + this.numberOfDevices = 0; + + this.record = null; + this.hackRecordViewer = null; + + // If the page isn't served over HTTPS there won't be mediaDevices + if (navigator.mediaDevices) { + this.updateNumberOfDevices = this.updateNumberOfDevices.bind(this); + // Safari doesn't support ondevicechange + if (!this.isSafari) { + navigator.mediaDevices.ondevicechange = event => this.updateNumberOfDevices(); + } + this.updateNumberOfDevices(); + } + + // FIXME this is abhorrent. Remove when peer lifecycle is properly decoupled + // from the React component's lifecycle. Any attempt at a half-baked + // decoupling will most probably generate problems - prlanzarin Dec 16 2021 + this.webRtcPeersRef = {}; + } + + defineProperties(obj) { + Object.keys(obj).forEach((key) => { + const privateKey = `_${key}`; + this[privateKey] = { + value: obj[key], + tracker: new Tracker.Dependency(), + }; + + Object.defineProperty(this, key, { + set: (value) => { + this[privateKey].value = value; + this[privateKey].tracker.changed(); + }, + get: () => { + this[privateKey].tracker.depend(); + return this[privateKey].value; + }, + }); + }); + } + + fetchNumberOfDevices(devices) { + const deviceIds = []; + devices.forEach((d) => { + const validDeviceId = d.deviceId !== '' && !deviceIds.includes(d.deviceId) + if (d.kind === 'videoinput' && validDeviceId) { + deviceIds.push(d.deviceId); + } + }); + + return deviceIds.length; + } + + updateNumberOfDevices(devices = null) { + if (devices) { + this.numberOfDevices = this.fetchNumberOfDevices(devices); + } else { + navigator.mediaDevices.enumerateDevices().then((devices) => { + this.numberOfDevices = this.fetchNumberOfDevices(devices); + }); + } + } + + joinVideo(deviceId) { + this.deviceId = deviceId; + Storage.setItem('isFirstJoin', false); + if (!this.isUserLocked()) { + const streamName = this.buildStreamName(Auth.userID, deviceId); + const stream = { + stream: streamName, + userId: Auth.userID, + name: Auth.fullname, + }; + setConnectingStream(stream); + setVideoState((curr) => ({ + ...curr, + isConnecting: true, + })); + } + } + + joinedVideo() { + setVideoState((curr) => ({ + ...curr, + isConnected: true, + isConnecting: false, + })); + this.stopConnectingStream(); + } + + storeDeviceIds(streams) { + let deviceIds = []; + streams.filter((s) => s.userId === Auth.userID).forEach((s) => { + deviceIds.push(s.deviceId); + } + ); + Session.set('deviceIds', deviceIds.join()); + } + + exitVideo(sendUserUnshareWebcam, streams) { + const { isConnected } = getVideoState(); + if (isConnected) { + logger.info({ + logCode: 'video_provider_unsharewebcam', + }, `Sending unshare all ${Auth.userID} webcams notification to meteor`); + + streams.filter((s) => s.userId === Auth.userID).forEach((s) => sendUserUnshareWebcam(s.stream)); + this.exitedVideo(); + } + } + + exitedVideo() { + this.stopConnectingStream(); + setVideoState((curr) => ({ + ...curr, + isConnected: false, + })); + } + + stopVideo(cameraId, sendUserUnshareWebcam, streams) { + const _streams = streams.filter((s) => s.userId === Auth.userID); + + const hasTargetStream = _streams.some((s) => s.stream === cameraId); + const hasOtherStream = _streams.some((s) => s.stream !== cameraId); + + // Check if the target (cameraId) stream exists in the remote collection. + // If it does, means it was successfully shared. So do the full stop procedure. + if (hasTargetStream) { + sendUserUnshareWebcam(cameraId); + } + + if (!hasOtherStream) { + // There's no other remote stream, meaning (OR) + // a) This was effectively the last webcam being unshared + // b) This was a connecting stream timing out (not effectively shared) + // For both cases, we clean everything up. + this.exitedVideo(); + } else { + // It was not the last webcam the user had successfully shared, + // nor was cameraId present in the server collection. + // Hence it's a connecting stream (not effectively shared) which timed out + this.stopConnectingStream(); + } + } + + getSharedDevices(streams) { + const devices = streams.filter((vs) => vs.userId === Auth.userID).map((vs) => vs.deviceId); + + return devices; + } + + sendUserShareWebcam(cameraId) { + makeCall('userShareWebcam', cameraId); + } + + sendUserUnshareWebcam(cameraId) { + makeCall('userUnshareWebcam', cameraId); + } + + getAuthenticatedURL() { + return Auth.authenticateURL(SFU_URL); + } + + shouldRenderPaginationToggle() { + // Only enable toggle if configured to do so and if we have a page size properly setup + return PAGINATION_TOGGLE_ENABLED && (this.getMyPageSize() > 0); + } + + isPaginationEnabled () { + return Settings.application.paginationEnabled; + } + + setNumberOfPages (numberOfPublishers, numberOfSubscribers, pageSize) { + if (pageSize === 0) return 0; + + // Page size refers only to the number of subscribers. Publishers are always + // shown, hence not accounted for + const nofPages = Math.ceil(numberOfSubscribers / pageSize); + + if (nofPages !== this.numberOfPages) { + this.numberOfPages = nofPages; + setVideoState((curr) => ({ + ...curr, + numberOfPages: nofPages, + })); + // Check if we have to page back on the current video page index due to a + // page ceasing to exist + if (nofPages === 0) { + this.currentVideoPageIndex = 0; + setVideoState((curr) => ({ + ...curr, + currentVideoPageIndex: 0, + })); + } else if ((this.currentVideoPageIndex + 1) > this.numberOfPages) { + this.getPreviousVideoPage(); + } + } + + return this.numberOfPages; + } + + getNumberOfPages () { + return this.numberOfPages; + } + + setCurrentVideoPageIndex (newVideoPageIndex) { + const { currentVideoPageIndex} = getVideoState(); + if (currentVideoPageIndex !== newVideoPageIndex) { + setVideoState((curr) => ({ + ...curr, + currentVideoPageIndex: newVideoPageIndex, + })); + } + } + + getCurrentVideoPageIndex () { + const { currentVideoPageIndex} = getVideoState(); + return currentVideoPageIndex; + } + + calculateNextPage () { + const { numberOfPages, currentVideoPageIndex } = getVideoState(); + if (numberOfPages === 0) { + return 0; + } + + return ((currentVideoPageIndex + 1) % numberOfPages + numberOfPages) % numberOfPages; + } + + calculatePreviousPage () { + const { numberOfPages, currentVideoPageIndex } = getVideoState(); + if (numberOfPages === 0) { + return 0; + } + + return ((currentVideoPageIndex - 1) % numberOfPages + numberOfPages) % numberOfPages; + } + + getNextVideoPage() { + const nextPage = this.calculateNextPage(); + this.setCurrentVideoPageIndex(nextPage); + } + + getPreviousVideoPage() { + const previousPage = this.calculatePreviousPage(); + this.setCurrentVideoPageIndex(previousPage); + } + + getPageSizeDictionary () { + // Dynamic page sizes are disabled. Fetch the stock page sizes. + if (!PAGINATION_THRESHOLDS_ENABLED || PAGINATION_THRESHOLDS.length <= 0) { + return !this.isMobile ? DESKTOP_PAGE_SIZES : MOBILE_PAGE_SIZES; + } + + // Dynamic page sizes are enabled. Get the user count, isolate the + // matching threshold entry, return the val. + let targetThreshold; + const userCount = UserListService.getUserCount(); + const processThreshold = (threshold = { + desktopPageSizes: DESKTOP_PAGE_SIZES, + mobilePageSizes: MOBILE_PAGE_SIZES + }) => { + // We don't demand that all page sizes should be set in pagination profiles. + // That saves us some space because don't necessarily need to scale mobile + // endpoints. + // If eg mobile isn't set, then return the default value. + if (!this.isMobile) { + return threshold.desktopPageSizes || DESKTOP_PAGE_SIZES; + } else { + return threshold.mobilePageSizes || MOBILE_PAGE_SIZES; + } + }; + + // Short-circuit: no threshold yet, return stock values (processThreshold has a default arg) + if (userCount < PAGINATION_THRESHOLDS[0].users) return processThreshold(); + + // Reverse search for the threshold where our participant count is directly equal or great + // The PAGINATION_THRESHOLDS config is sorted when imported. + for (let mapIndex = PAGINATION_THRESHOLDS.length - 1; mapIndex >= 0; --mapIndex) { + targetThreshold = PAGINATION_THRESHOLDS[mapIndex]; + if (targetThreshold.users <= userCount) { + return processThreshold(targetThreshold); + } + } + } + + setPageSize (size) { + if (this.pageSize !== size) { + this.pageSize = size; + setVideoState((curr) => ({ + ...curr, + pageSize: size, + })); + } + + return this.pageSize; + } + + getMyPageSize () { + let size; + const myRole = this.getMyRole(); + const pageSizes = this.getPageSizeDictionary(); + switch (myRole) { + case ROLE_MODERATOR: + size = pageSizes.moderator; + break; + case ROLE_VIEWER: + default: + size = pageSizes.viewer + } + + return this.setPageSize(size); + } + + getGridSize () { + let size; + const myRole = this.getMyRole(); + const gridSizes = !this.isMobile ? DESKTOP_GRID_SIZES : MOBILE_GRID_SIZES; + + switch (myRole) { + case ROLE_MODERATOR: + size = gridSizes.moderator; + break; + case ROLE_VIEWER: + default: + size = gridSizes.viewer + } + + return size; + } + + getVideoPage (streams, pageSize) { + // Publishers are taken into account for the page size calculations. They + // also appear on every page. Same for pinned user. + const [filtered, others] = partition(streams, (vs) => Auth.userID === vs.userId || vs.pin); + + // Separate pin from local cameras + const [pin, mine] = partition(filtered, (vs) => vs.pin); + + // Recalculate total number of pages + this.setNumberOfPages(filtered.length, others.length, pageSize); + const chunkIndex = this.currentVideoPageIndex * pageSize; + + // This is an extra check because pagination is globally in effect (hard + // limited page sizes, toggles on), but we might still only have one page. + // Use the default sorting method if that's the case. + const sortingMethod = (this.numberOfPages > 1) ? PAGINATION_SORTING : DEFAULT_SORTING; + const paginatedStreams = sortVideoStreams(others, sortingMethod) + .slice(chunkIndex, (chunkIndex + pageSize)) || []; + + if (getSortingMethod(sortingMethod).localFirst) { + return [...pin, ...mine, ...paginatedStreams]; + } + return [...pin, ...paginatedStreams, ...mine]; + } + + getUsersIdFromVideoStreams(streams) { + const usersId = streams.map(user => user.userId); + + return usersId; + } + + getVideoPinByUser(userId) { + const user = Users.findOne({ userId }, { fields: { pin: 1 } }); + + return user?.pin || false; + } + + isGridEnabled() { + return Session.get('isGridEnabled'); + } + + getVideoStreams(_streams) { + const pageSize = this.getMyPageSize(); + const isPaginationDisabled = !this.isPaginationEnabled() || pageSize === 0; + const { neededDataTypes } = isPaginationDisabled + ? getSortingMethod(DEFAULT_SORTING) + : getSortingMethod(PAGINATION_SORTING); + const isGridEnabled = this.isGridEnabled(); + let gridUsers = []; + let users = []; + + if (isGridEnabled) { + users = Users.find( + { meetingId: Auth.meetingID }, + { fields: { loggedOut: 1, left: 1, ...neededDataTypes} }, + ).fetch(); + } + + let streams = _streams; + + // Data savings enabled will only show local streams + const { viewParticipantsWebcams } = Settings.dataSaving; + if (!viewParticipantsWebcams) streams = this.filterLocalOnly(streams); + + const connectingStream = this.getConnectingStream(streams); + if (connectingStream) streams.push(connectingStream); + + // Pagination is either explicitly disabled or pagination is set to 0 (which + // is equivalent to disabling it), so return the mapped streams as they are + // which produces the original non paginated behaviour + if (isPaginationDisabled) { + if (isGridEnabled) { + const streamUsers = streams.map((stream) => stream.userId); + + gridUsers = users.filter( + (user) => !user.loggedOut && !user.left && !streamUsers.includes(user.userId) + ).map((user) => ({ + isGridItem: true, + ...user, + })); + } + + return { + streams: sortVideoStreams(streams, DEFAULT_SORTING), + gridUsers, + totalNumberOfStreams: streams.length + }; + } + + const paginatedStreams = this.getVideoPage(streams, pageSize); + + if (isGridEnabled) { + const streamUsers = paginatedStreams.map((stream) => stream.userId); + + gridUsers = users.filter( + (user) => !user.loggedOut && !user.left && !streamUsers.includes(user.userId) + ).map((user) => ({ + isGridItem: true, + ...user, + })); + } + + return { streams: paginatedStreams, gridUsers, totalNumberOfStreams: streams.length }; + } + + fetchVideoStreams(_streams) { + const pageSize = this.getMyPageSize(); + const isPaginationDisabled = !this.isPaginationEnabled() || pageSize === 0; + + let streams = [..._streams]; + + const connectingStream = this.getConnectingStream(streams); + if (connectingStream) { + streams.push(connectingStream); + } + + return streams; + } + + getGridUsers(users, streams) { + const isGridEnabled = this.isGridEnabled(); + const gridSize = this.getGridSize(); + + let gridUsers = []; + + if (isGridEnabled) { + const streamUsers = streams.map((stream) => stream.userId); + + gridUsers = users.filter( + (user) => !user.loggedOut && !user.left && !streamUsers.includes(user.userId), + ).map((user) => ({ + isGridItem: true, + ...user, + })).slice(0, gridSize - streams.length); + } + return gridUsers; + } + + stopConnectingStream() { + this.deviceId = null; + setConnectingStream(null); + } + + getConnectingStream(streams) { + let connectingStream; + + if (this.isConnecting) { + if (this.deviceId) { + const stream = this.buildStreamName(Auth.userID, this.deviceId); + if (!this.hasStream(streams, stream) && !this.isUserLocked()) { + connectingStream = { + stream, + userId: Auth.userID, + name: Auth.fullname, + }; + } else { + // Connecting stream is already stored at database + this.stopConnectingStream(); + } + } else { + logger.error({ + logCode: 'video_provider_missing_deviceid', + }, 'Could not retrieve a valid deviceId'); + } + } + + return connectingStream; + } + + buildStreamName(userId, deviceId) { + return `${userId}${TOKEN}${deviceId}`; + } + + hasVideoStream(streams) { + const videoStreams = streams.find((vs) => vs.userId === Auth.userID); + return !!videoStreams; + } + + hasStream(streams, stream) { + return streams.find(s => s.stream === stream); + } + + getMediaServerAdapter() { + return getFromMeetingSettings('media-server-video', DEFAULT_VIDEO_MEDIA_SERVER); + } + + getMyRole () { + return Users.findOne({ userId: Auth.userID }, + { fields: { role: 1 } })?.role; + } + + getRecord() { + if (this.record === null) { + this.record = getFromUserSettings('bbb_record_video', true); + } + + // TODO: Remove this + // This is a hack to handle a missing piece at the backend of a particular deploy. + // If, at the time the video is shared, the user has a viewer role and + // meta_hack-record-viewer-video is 'false' this user won't have this video + // stream recorded. + if (this.hackRecordViewer === null) { + const value = getFromMeetingSettings('hack-record-viewer-video', null); + this.hackRecordViewer = value ? value.toLowerCase() === 'true' : true; + } + + const hackRecord = this.getMyRole() === ROLE_MODERATOR || this.hackRecordViewer; + + return this.record && hackRecord; + } + + filterModeratorOnly(streams) { + const amIViewer = this.getMyRole() === ROLE_VIEWER; + + if (amIViewer) { + const moderators = Users.find( + { + role: ROLE_MODERATOR, + }, + { fields: { userId: 1 } }, + ).fetch().map(user => user.userId); + + return streams.reduce((result, stream) => { + const { userId } = stream; + + const isModerator = moderators.includes(userId); + const isMe = Auth.userID === userId; + + if (isModerator || isMe) result.push(stream); + + return result; + }, []); + } + return streams; + } + + filterLocalOnly(streams) { + return streams.filter(stream => stream.userId === Auth.userID); + } + + disableCam() { + const m = Meetings.findOne({ meetingId: Auth.meetingID }, + { fields: { 'lockSettings.disableCam': 1 } }); + return m.lockSettings ? m.lockSettings.disableCam : false; + } + + webcamsOnlyForModerator() { + const meeting = Meetings.findOne({ meetingId: Auth.meetingID }, + { fields: { 'usersPolicies.webcamsOnlyForModerator': 1 } }); + const user = Users.findOne({ userId: Auth.userID }, { fields: { locked: 1, role: 1 } }); + + if (meeting?.usersPolicies && user?.role !== ROLE_MODERATOR && user?.locked) { + return meeting.usersPolicies.webcamsOnlyForModerator; + } + return false; + } + + hasCapReached() { + const meeting = Meetings.findOne( + { meetingId: Auth.meetingID }, + { + fields: { + meetingCameraCap: 1, + 'usersPolicies.userCameraCap': 1, + }, + }, + ); + + // If the meeting prop data is unreachable, force a safe return + if ( + meeting?.usersPolicies === undefined + || !meeting?.meetingCameraCap === undefined + ) return true; + const { meetingCameraCap } = meeting; + const { userCameraCap } = meeting.usersPolicies; + + const meetingCap = meetingCameraCap !== 0 && this.getVideoStreamsCount() >= meetingCameraCap; + const userCap = userCameraCap !== 0 && this.getLocalVideoStreamsCount() >= userCameraCap; + + return meetingCap || userCap; + } + + getVideoStreamsCount(streams) { + return streams.length; + } + + getLocalVideoStreamsCount(streams) { + const localStreams = streams.filter((vs) => vs.userId === Auth.userID); + + return localStreams.length; + } + + getInfo() { + const m = Meetings.findOne({ meetingId: Auth.meetingID }, + { fields: { 'voiceSettings.voiceConf': 1 } }); + const voiceBridge = m.voiceSettings ? m.voiceSettings.voiceConf : null; + return { + userId: Auth.userID, + userName: Auth.fullname, + meetingId: Auth.meetingID, + sessionToken: Auth.sessionToken, + voiceBridge, + }; + } + + mirrorOwnWebcam(userId = null) { + // only true if setting defined and video ids match + const isOwnWebcam = userId ? Auth.userID === userId : true; + const isEnabledMirroring = getFromUserSettings('bbb_mirror_own_webcam', MIRROR_WEBCAM); + return isOwnWebcam && isEnabledMirroring; + } + + isPinEnabled() { + return PIN_WEBCAM; + } + + // In user-list it is necessary to check if the user is sharing his webcam + isVideoPinEnabledForCurrentUser(isModerator) { + const isBreakout = meetingIsBreakout(); + const isPinEnabled = this.isPinEnabled(); + + return !!(isModerator + && isPinEnabled + && !isBreakout); + } + + getMyStreamId(deviceId, streams) { + const videoStream = streams.find((vs) => vs.userId === Auth.userID && vs.deviceId === deviceId); + return videoStream ? videoStream.stream : null; + } + + isUserLocked() { + return !!Users.findOne({ + userId: Auth.userID, + locked: true, + role: { $ne: ROLE_MODERATOR }, + }, { fields: {} }) && this.disableCam(); + } + + lockUser(sendUserUnshareWebcam, streams) { + const { isConnected } = getVideoState() + if (isConnected) { + this.exitVideo(sendUserUnshareWebcam, streams); + } + } + + isLocalStream(cameraId) { + return cameraId?.startsWith(Auth.userID); + } + + playStart(cameraId) { + if (this.isLocalStream(cameraId)) { + this.sendUserShareWebcam(cameraId); + this.joinedVideo(); + } + } + + getCameraProfile() { + const profileId = BBBStorage.getItem('WebcamProfileId') || ''; + const cameraProfile = CAMERA_PROFILES.find(profile => profile.id === profileId) + || CAMERA_PROFILES.find(profile => profile.default) + || CAMERA_PROFILES[0]; + const deviceId = BBBStorage.getItem('WebcamDeviceId'); + if (deviceId) { + cameraProfile.constraints = cameraProfile.constraints || {}; + cameraProfile.constraints.deviceId = { exact: deviceId }; + } + + return cameraProfile; + } + + addCandidateToPeer(peer, candidate, cameraId) { + peer.addIceCandidate(candidate).catch((error) => { + if (error) { + // Just log the error. We can't be sure if a candidate failure on add is + // fatal or not, so that's why we have a timeout set up for negotiations + // and listeners for ICE state transitioning to failures, so we won't + // act on it here + logger.error({ + logCode: 'video_provider_addicecandidate_error', + extraInfo: { + cameraId, + error, + }, + }, `Adding ICE candidate failed for ${cameraId} due to ${error.message}`); + } + }); + } + + processInboundIceQueue(peer, cameraId) { + while (peer.inboundIceQueue.length) { + const candidate = peer.inboundIceQueue.shift(); + this.addCandidateToPeer(peer, candidate, cameraId); + } + } + + onBeforeUnload(sendUserUnshareWebcam, streams) { + this.exitVideo(sendUserUnshareWebcam, streams); + } + + getStatus() { + const { isConnected, isConnecting } = getVideoState() + if (isConnecting) return 'videoConnecting'; + if (isConnected) return 'connected'; + return 'disconnected'; + } + + disableReason() { + const locks = { + videoLocked: this.isUserLocked(), + camCapReached: this.hasCapReached() && !this.hasVideoStream(), + meteorDisconnected: !Meteor.status().connected + }; + const locksKeys = Object.keys(locks); + const disableReason = locksKeys.filter( i => locks[i]).shift(); + return disableReason ? disableReason : false; + } + + getRole(isLocal) { + return isLocal ? 'share' : 'viewer'; + } + + getUserParameterProfile() { + if (this.userParameterProfile === null) { + this.userParameterProfile = getFromUserSettings( + 'bbb_preferred_camera_profile', + (CAMERA_PROFILES.find(i => i.default) || {}).id || null, + ); + } + + return this.userParameterProfile; + } + + isMultipleCamerasEnabled() { + // Multiple cameras shouldn't be enabled with video preview skipping + // Mobile shouldn't be able to share more than one camera at the same time + // Safari needs to implement devicechange event for safe device control + return MULTIPLE_CAMERAS + && !VideoPreviewService.getSkipVideoPreview() + && !this.isMobile + && !this.isSafari + && this.numberOfDevices > 1; + } + + isProfileBetter (newProfileId, originalProfileId) { + return CAMERA_PROFILES.findIndex(({ id }) => id === newProfileId) + > CAMERA_PROFILES.findIndex(({ id }) => id === originalProfileId); + } + + applyBitrate (peer, bitrate) { + const peerConnection = peer.peerConnection; + if ('RTCRtpSender' in window + && 'setParameters' in window.RTCRtpSender.prototype + && 'getParameters' in window.RTCRtpSender.prototype) { + peerConnection.getSenders().forEach(sender => { + const { track } = sender; + if (track && track.kind === 'video') { + const parameters = sender.getParameters(); + const normalizedBitrate = bitrate * 1000; + + // The encoder parameters might not be up yet; if that's the case, + // add a filler object so we can alter the parameters anyways + if (parameters.encodings == null || parameters.encodings.length === 0) { + parameters.encodings = [{}]; + } + + // Only reset bitrate if it changed in some way to avoid encoder fluctuations + if (parameters.encodings[0].maxBitrate !== normalizedBitrate) { + parameters.encodings[0].maxBitrate = normalizedBitrate; + sender.setParameters(parameters) + .then(() => { + logger.info({ + logCode: 'video_provider_bitratechange', + extraInfo: { bitrate }, + }, `Bitrate changed: ${bitrate}`); + }) + .catch(error => { + logger.warn({ + logCode: 'video_provider_bitratechange_failed', + extraInfo: { bitrate, errorMessage: error.message, errorCode: error.code }, + }, `Bitrate change failed.`); + }); + } + } + }) + } + } + + // Some browsers (mainly iOS Safari) garble the stream if a constraint is + // reconfigured without propagating previous height/width info + reapplyResolutionIfNeeded (track, constraints) { + if (typeof track.getSettings !== 'function') { + return constraints; + } + + const trackSettings = track.getSettings(); + + if (trackSettings.width && trackSettings.height) { + return { + ...constraints, + width: trackSettings.width, + height: trackSettings.height, + }; + } + + return constraints; + } + + applyCameraProfile (peer, profileId) { + const profile = CAMERA_PROFILES.find((targetProfile) => targetProfile.id === profileId); + + // When this should be skipped: + // 1 - Badly defined profile + // 2 - Badly defined peer (ie {}) + // 3 - The target profile is already applied + // 4 - The targetr profile is better than the original profile + if (!profile + || peer == null + || peer.peerConnection == null + || peer.currentProfileId === profileId + || this.isProfileBetter(profileId, peer.originalProfileId)) { + return; + } + + const { bitrate, constraints } = profile; + + if (bitrate) this.applyBitrate(peer, bitrate); + + if (CAMERA_QUALITY_THR_CONSTRAINTS + && constraints + && typeof constraints === 'object' + ) { + peer.peerConnection.getSenders().forEach((sender) => { + const { track } = sender; + if (track && track.kind === 'video' && typeof track.applyConstraints === 'function') { + const normalizedVideoConstraints = this.reapplyResolutionIfNeeded(track, constraints); + track.applyConstraints(normalizedVideoConstraints) + .catch((error) => { + logger.warn({ + logCode: 'video_provider_constraintchange_failed', + extraInfo: { errorName: error.name, errorCode: error.code }, + }, 'Error applying camera profile'); + }); + } + }); + } + + logger.info({ + logCode: 'video_provider_profile_applied', + extraInfo: { profileId }, + }, `New camera profile applied: ${profileId}`); + + peer.currentProfileId = profileId; + } + + getThreshold (numberOfPublishers) { + let targetThreshold = { threshold: 0, profile: 'original' }; + let finalThreshold = { threshold: 0, profile: 'original' }; + + for(let mapIndex = 0; mapIndex < CAMERA_QUALITY_THRESHOLDS.length; mapIndex++) { + targetThreshold = CAMERA_QUALITY_THRESHOLDS[mapIndex]; + if (targetThreshold.threshold <= numberOfPublishers) { + finalThreshold = targetThreshold; + } + } + + return finalThreshold; + } + + getPreloadedStream () { + if (this.deviceId == null) return; + return VideoPreviewService.getStream(this.deviceId); + } + + /** + * Get all active video peers. + * @returns An Object containing the reference for all active peers peers + */ + getActivePeers(streams) { + const videoData = this.getVideoStreams(streams); + + if (!videoData) return null; + + const { streams: activeVideoStreams } = videoData; + + if (!activeVideoStreams) return null; + + const activePeers = {}; + + activeVideoStreams.forEach((stream) => { + if (this.webRtcPeersRef[stream.stream]) { + activePeers[stream.stream] = this.webRtcPeersRef[stream.stream].peerConnection; + } + }); + + return activePeers; + } + + /** + * Get stats about all active video peer. + * We filter the status based on FILTER_VIDEO_STATS constant. + * + * For more information see: + * https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats + * and + * https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsReport + * @returns An Object containing the information about each active peer. + * The returned object follows the format: + * { + * peerId: RTCStatsReport + * } + */ + async getStats(streams) { + const peers = this.getActivePeers(streams); + + if (!peers) return null; + + const stats = {}; + + await Promise.all( + Object.keys(peers).map(async (peerId) => { + const peerStats = await peers[peerId].getStats(); + + const videoStats = {}; + + peerStats.forEach((stat) => { + if (FILTER_VIDEO_STATS.includes(stat.type)) { + videoStats[stat.type] = stat; + } + }); + stats[peerId] = videoStats; + }) + ); + + return stats; + } + + updatePeerDictionaryReference(newRef) { + this.webRtcPeersRef = newRef; + } +} + +const videoService = new VideoService(); + +export default { + storeDeviceIds: (streams) => videoService.storeDeviceIds(streams), + exitVideo: (sendUserUnshareWebcam, streams) => videoService.exitVideo(sendUserUnshareWebcam, streams), + joinVideo: deviceId => videoService.joinVideo(deviceId), + stopVideo: (cameraId, sendUserUnshareWebcam, streams) => videoService.stopVideo( + cameraId, + sendUserUnshareWebcam, + streams, + ), + getVideoStreams: (streams) => videoService.getVideoStreams(streams), + getInfo: () => videoService.getInfo(), + getMyStreamId: (deviceId, streams) => videoService.getMyStreamId(deviceId, streams), + isUserLocked: () => videoService.isUserLocked(), + lockUser: (sendUserUnshareWebcam, streams) => videoService.lockUser(sendUserUnshareWebcam, streams), + getAuthenticatedURL: () => videoService.getAuthenticatedURL(), + isLocalStream: cameraId => videoService.isLocalStream(cameraId), + hasVideoStream: (streams) => videoService.hasVideoStream(streams), + getStatus: () => videoService.getStatus(), + disableReason: () => videoService.disableReason(), + playStart: cameraId => videoService.playStart(cameraId), + getCameraProfile: () => videoService.getCameraProfile(), + addCandidateToPeer: (peer, candidate, cameraId) => videoService.addCandidateToPeer(peer, candidate, cameraId), + processInboundIceQueue: (peer, cameraId) => videoService.processInboundIceQueue(peer, cameraId), + getRole: isLocal => videoService.getRole(isLocal), + getMediaServerAdapter: () => videoService.getMediaServerAdapter(), + getRecord: () => videoService.getRecord(), + getSharedDevices: (streams) => videoService.getSharedDevices(streams), + getUserParameterProfile: () => videoService.getUserParameterProfile(), + isMultipleCamerasEnabled: () => videoService.isMultipleCamerasEnabled(), + mirrorOwnWebcam: userId => videoService.mirrorOwnWebcam(userId), + hasCapReached: () => videoService.hasCapReached(), + onBeforeUnload: (sendUserUnshareWebcam, streams) => videoService.onBeforeUnload(sendUserUnshareWebcam, streams), + notify: message => notify(message, 'error', 'video'), + updateNumberOfDevices: devices => videoService.updateNumberOfDevices(devices), + applyCameraProfile: debounce( + videoService.applyCameraProfile.bind(videoService), + CAMERA_QUALITY_THR_DEBOUNCE, + { leading: false, trailing: true }, + ), + getThreshold: (numberOfPublishers) => videoService.getThreshold(numberOfPublishers), + isPaginationEnabled: () => videoService.isPaginationEnabled(), + getNumberOfPages: () => videoService.getNumberOfPages(), + getCurrentVideoPageIndex: () => videoService.getCurrentVideoPageIndex(), + getPreviousVideoPage: () => videoService.getPreviousVideoPage(), + getNextVideoPage: () => videoService.getNextVideoPage(), + getPageChangeDebounceTime: () => { return PAGE_CHANGE_DEBOUNCE_TIME }, + getUsersIdFromVideoStreams: (streams) => videoService.getUsersIdFromVideoStreams(streams), + shouldRenderPaginationToggle: () => videoService.shouldRenderPaginationToggle(), + getVideoPinByUser: (userId) => videoService.getVideoPinByUser(userId), + isVideoPinEnabledForCurrentUser: (user) => videoService.isVideoPinEnabledForCurrentUser(user), + isPinEnabled: () => videoService.isPinEnabled(), + getPreloadedStream: () => videoService.getPreloadedStream(), + getStats: (streams) => videoService.getStats(streams), + updatePeerDictionaryReference: (newRef) => videoService.updatePeerDictionaryReference(newRef), + joinedVideo: () => videoService.joinedVideo(), + fetchVideoStreams: () => videoService.fetchVideoStreams(), + getGridUsers: (users = [], streams = []) => videoService.getGridUsers(users, streams), + webcamsOnlyForModerators: () => videoService.webcamsOnlyForModerator(), + isGridEnabled: videoService.isGridEnabled, + setPageSize: videoService.setPageSize, + filterLocalOnly: videoService.filterLocalOnly, + exitedVideo: () => videoService.exitedVideo() +}; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/state.ts b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/state.ts new file mode 100644 index 0000000000..31ba0751d3 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/state.ts @@ -0,0 +1,77 @@ +import { makeVar, useReactiveVar } from '@apollo/client'; +import createUseLocalState from '/imports/ui/core/local-states/createUseLocalState'; + +const [useVideoState, setVideoState, videoState] = createUseLocalState({ + isConnecting: false, + isConnected: false, + currentVideoPageIndex: 0, + numberOfPages: 0, + pageSize: 0, + userId: null, +}); + +const getVideoState = () => videoState(); + +type ConnectingStream = { + stream: string; + name: string; + userId: string; +} | null; + +const connectingStream = makeVar(null); + +const useConnectingStream = (streams: { stream: string }[]) => { + const connecting = useReactiveVar(connectingStream); + + if (!connecting) return null; + + const hasStream = streams.find((s) => s.stream === connecting.stream); + + if (hasStream) { + return null; + } + + return connecting; +}; + +const setConnectingStream = (stream: ConnectingStream) => { + connectingStream(stream); +}; + +type Stream = { + stream: string; + deviceId: string; + userId: string; + name: string; + sortName: string; + pin: boolean; + floor: boolean; + lastFloorTime: string; + isUserModerator: boolean; +} + +const streams = makeVar([]); + +const setStreams = (vs: Stream[]) => { + streams(vs); +}; + +export { + useVideoState, + setVideoState, + getVideoState, + useConnectingStream, + setConnectingStream, + setStreams, + streams, +}; + +export default { + useVideoState, + setVideoState, + getVideoState, + useConnectingStream, + setConnectingStream, + setStreams, + streams, +}; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/stream-sorting.ts b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/stream-sorting.ts new file mode 100644 index 0000000000..dcee88b9dc --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/stream-sorting.ts @@ -0,0 +1,140 @@ +import UserListService from '/imports/ui/components/user-list/service'; +import Auth from '/imports/ui/services/auth'; + +const DEFAULT_SORTING_MODE = 'LOCAL_ALPHABETICAL'; + +// pin first +export const sortPin = (s1, s2) => { + if (s1.pin) { + return -1; + } if (s2.pin) { + return 1; + } + return 0; +}; + +export const mandatorySorting = (s1, s2) => sortPin(s1, s2); + +// lastFloorTime, descending +export const sortVoiceActivity = (s1, s2) => { + if (s2.lastFloorTime < s1.lastFloorTime) { + return -1; + } else if (s2.lastFloorTime > s1.lastFloorTime) { + return 1; + } else return 0; +}; + +// pin -> lastFloorTime (descending) -> alphabetical -> local +export const sortVoiceActivityLocal = (s1, s2) => { + if (s1.userId === Auth.userID) { + return 1; + } if (s2.userId === Auth.userID) { + return -1; + } + + return mandatorySorting(s1, s2) + || sortVoiceActivity(s1, s2) + || UserListService.sortUsersByName(s1, s2); +}; + +// pin -> local -> lastFloorTime (descending) -> alphabetical +export const sortLocalVoiceActivity = (s1, s2) => mandatorySorting(s1, s2) + || UserListService.sortUsersByCurrent(s1, s2) + || sortVoiceActivity(s1, s2) + || UserListService.sortUsersByName(s1, s2); + +// pin -> local -> alphabetic +export const sortLocalAlphabetical = (s1, s2) => mandatorySorting(s1, s2) + || UserListService.sortUsersByCurrent(s1, s2) + || UserListService.sortUsersByName(s1, s2); + +export const sortPresenter = (s1, s2) => { + if (UserListService.isUserPresenter(s1.userId)) { + return -1; + } else if (UserListService.isUserPresenter(s2.userId)) { + return 1; + } else return 0; +}; + +// pin -> local -> presenter -> alphabetical +export const sortLocalPresenterAlphabetical = (s1, s2) => mandatorySorting(s1, s2) + || UserListService.sortUsersByCurrent(s1, s2) + || sortPresenter(s1, s2) + || UserListService.sortUsersByName(s1, s2); + +// SORTING_METHODS: registrar of configurable video stream sorting modes +// Keys are the method name (String) which are to be configured in settings.yml +// ${streamSortingMethod} flag. +// +// Values are a objects which describe the sorting mode: +// - sortingMethod (function): a sorting function defined in this module +// - neededData (Object): data members that will be fetched from the server's +// video-streams collection +// - filter (Boolean): whether the sorted stream list has to be post processed +// to remove uneeded attributes. The needed attributes are: userId, streams +// and name. Anything other than that is superfluous. +// - localFirst (Boolean): true pushes local streams to the beginning of the list, +// false to the end +// The reason why this flags exists is due to pagination: local streams are +// stripped out of the streams list prior to sorting+partiotioning. They're +// added (pushed) afterwards. To avoid re-sorting the page, this flag indicates +// where it should go. +// +// To add a new sorting flavor: +// 1 - implement a sorting function, add it here (like eg sortPresenterAlphabetical) +// 1.1.: the sorting function has the same behaviour as a regular .sort callback +// 2 - add an entry to SORTING_METHODS, the key being the name to be used +// in settings.yml and the value object like the aforementioned +const MANDATORY_DATA_TYPES = { + userId: 1, stream: 1, name: 1, sortName: 1, deviceId: 1, floor: 1, pin: 1, +}; +const SORTING_METHODS = Object.freeze({ + // Default + LOCAL_ALPHABETICAL: { + sortingMethod: sortLocalAlphabetical, + neededDataTypes: MANDATORY_DATA_TYPES, + localFirst: true, + }, + VOICE_ACTIVITY_LOCAL: { + sortingMethod: sortVoiceActivityLocal, + neededDataTypes: { + lastFloorTime: 1, floor: 1, ...MANDATORY_DATA_TYPES, + }, + filter: true, + localFirst: false, + }, + LOCAL_VOICE_ACTIVITY: { + sortingMethod: sortLocalVoiceActivity, + neededDataTypes: { + lastFloorTime: 1, floor: 1, ...MANDATORY_DATA_TYPES, + }, + filter: true, + localFirst: true, + }, + LOCAL_PRESENTER_ALPHABETICAL: { + sortingMethod: sortLocalPresenterAlphabetical, + neededDataTypes: MANDATORY_DATA_TYPES, + localFirst: true, + } +}); + +export const getSortingMethod = (identifier) => { + return SORTING_METHODS[identifier] || SORTING_METHODS[DEFAULT_SORTING_MODE]; +}; + +export const sortVideoStreams = (streams, mode) => { + const { sortingMethod, filter } = getSortingMethod(mode); + const sorted = streams.sort(sortingMethod); + + if (!filter) return sorted; + + return sorted.map(videoStream => ({ + stream: videoStream.stream, + isGridItem: videoStream?.isGridItem, + userId: videoStream.userId, + name: videoStream.name, + sortName: videoStream.sortName, + floor: videoStream.floor, + pin: videoStream.pin, + })); +}; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-button/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-button/component.jsx new file mode 100755 index 0000000000..ab68e4583a --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-provider-graphql/video-button/component.jsx @@ -0,0 +1,274 @@ +import React, { memo, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji'; +import VideoService from '../service'; +import { defineMessages, injectIntl } from 'react-intl'; +import Styled from './styles'; +import deviceInfo from '/imports/utils/deviceInfo'; +import { debounce } from '/imports/utils/debounce'; +import BBBMenu from '/imports/ui/components/common/menu/component'; +import { isVirtualBackgroundsEnabled } from '/imports/ui/services/features'; +import Button from '/imports/ui/components/common/button/component'; +import VideoPreviewContainer from '/imports/ui/components/video-preview/container'; +import { CameraSettingsDropdownItemType } from 'bigbluebutton-html-plugin-sdk/dist/cjs/extensible-areas/camera-settings-dropdown-item/enums'; +import Settings from '/imports/ui/services/settings'; + +const ENABLE_WEBCAM_SELECTOR_BUTTON = window.meetingClientSettings.public.app.enableWebcamSelectorButton; +const ENABLE_CAMERA_BRIGHTNESS = window.meetingClientSettings.public.app.enableCameraBrightness; + +const intlMessages = defineMessages({ + videoSettings: { + id: 'app.video.videoSettings', + description: 'Open video settings', + }, + visualEffects: { + id: 'app.video.visualEffects', + description: 'Visual effects label', + }, + joinVideo: { + id: 'app.video.joinVideo', + description: 'Join video button label', + }, + leaveVideo: { + id: 'app.video.leaveVideo', + description: 'Leave video button label', + }, + advancedVideo: { + id: 'app.video.advancedVideo', + description: 'Open advanced video label', + }, + videoLocked: { + id: 'app.video.videoLocked', + description: 'video disabled label', + }, + videoConnecting: { + id: 'app.video.connecting', + description: 'video connecting label', + }, + camCapReached: { + id: 'app.video.meetingCamCapReached', + description: 'meeting camera cap label', + }, + meteorDisconnected: { + id: 'app.video.clientDisconnected', + description: 'Meteor disconnected label', + }, +}); + +const JOIN_VIDEO_DELAY_MILLISECONDS = 500; + +const propTypes = { + intl: PropTypes.object.isRequired, + hasVideoStream: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + cameraSettingsDropdownItems: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + type: PropTypes.string, + })).isRequired, + sendUserUnshareWebcam: PropTypes.func.isRequired, + setLocalSettings: PropTypes.func.isRequired, +}; + +const JoinVideoButton = ({ + intl, + hasVideoStream, + status, + disableReason, + updateSettings, + cameraSettingsDropdownItems, + sendUserUnshareWebcam, + setLocalSettings, + streams, + exitVideo: exit, +}) => { + const { isMobile } = deviceInfo; + const isMobileSharingCamera = hasVideoStream && isMobile; + const isDesktopSharingCamera = hasVideoStream && !isMobile; + const shouldEnableWebcamSelectorButton = ENABLE_WEBCAM_SELECTOR_BUTTON + && isDesktopSharingCamera; + const shouldEnableWebcamVisualEffectsButton = (isVirtualBackgroundsEnabled() + || ENABLE_CAMERA_BRIGHTNESS) + && hasVideoStream + && !isMobile; + const exitVideo = () => isDesktopSharingCamera && (!VideoService.isMultipleCamerasEnabled() + || shouldEnableWebcamSelectorButton); + + const [propsToPassModal, setPropsToPassModal] = useState({}); + const [forceOpen, setForceOpen] = useState(false); + const [isVideoPreviewModalOpen, setVideoPreviewModalIsOpen] = useState(false); + const [wasSelfViewDisabled, setWasSelfViewDisabled] = useState(false); + + useEffect(() => { + const isSelfViewDisabled = Settings.application.selfViewDisable; + + if (isVideoPreviewModalOpen && isSelfViewDisabled) { + setWasSelfViewDisabled(true); + const obj = { + application: + { ...Settings.application, selfViewDisable: false }, + }; + updateSettings(obj, null, setLocalSettings); + } + }, [isVideoPreviewModalOpen]); + + const handleOnClick = debounce(() => { + switch (status) { + case 'videoConnecting': + VideoService.stopVideo(undefined, sendUserUnshareWebcam, streams); + break; + case 'connected': + default: + if (exitVideo()) { + exit(); + } else { + setForceOpen(isMobileSharingCamera); + setVideoPreviewModalIsOpen(true); + } + } + }, JOIN_VIDEO_DELAY_MILLISECONDS); + + const handleOpenAdvancedOptions = (callback) => { + if (callback) callback(); + setForceOpen(isDesktopSharingCamera); + setVideoPreviewModalIsOpen(true); + }; + + const getMessageFromStatus = () => { + let statusMessage = status; + if (status !== 'videoConnecting') { + statusMessage = exitVideo() ? 'leaveVideo' : 'joinVideo'; + } + return statusMessage; + }; + + const label = disableReason + ? intl.formatMessage(intlMessages[disableReason]) + : intl.formatMessage(intlMessages[getMessageFromStatus()]); + + const isSharing = hasVideoStream || status === 'videoConnecting'; + + const renderUserActions = () => { + const actions = []; + + if (shouldEnableWebcamSelectorButton) { + actions.push( + { + key: 'advancedVideo', + label: intl.formatMessage(intlMessages.advancedVideo), + onClick: () => handleOpenAdvancedOptions(), + dataTest: 'advancedVideoSettingsButton', + }, + ); + } + + if (shouldEnableWebcamVisualEffectsButton) { + actions.push( + { + key: 'virtualBgSelection', + label: intl.formatMessage(intlMessages.visualEffects), + onClick: () => handleOpenAdvancedOptions(( + ) => setPropsToPassModal({ isVisualEffects: true })), + }, + ); + } + + if (actions.length === 0) return null; + const customStyles = { top: '-3.6rem' }; + + cameraSettingsDropdownItems.forEach((plugin) => { + switch (plugin.type) { + case CameraSettingsDropdownItemType.OPTION: + actions.push({ + key: plugin.id, + label: plugin.label, + onClick: plugin.onClick, + icon: plugin.icon, + }); + break; + case CameraSettingsDropdownItemType.SEPARATOR: + actions.push({ + key: plugin.id, + isSeparator: true, + }); + break; + default: + break; + } + }); + return ( + + )} + actions={actions} + opts={{ + id: 'video-dropdown-menu', + keepMounted: true, + transitionDuration: 0, + elevation: 3, + getcontentanchorel: null, + fullwidth: 'true', + anchorOrigin: { vertical: 'top', horizontal: 'center' }, + transformOrigin: { vertical: 'top', horizontal: 'center' }, + }} + /> + ); + }; + + return ( + <> + +