Merge remote-tracking branch 'upstream/v3.0.x-release' into i-19517

This commit is contained in:
André 2024-02-07 12:54:40 -03:00
commit 516e2a7260
129 changed files with 1336 additions and 1776 deletions

View File

@ -56,7 +56,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 +65,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 +74,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
}
}

View File

@ -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

View File

@ -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)

View File

@ -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)
}

View File

@ -24,7 +24,10 @@ case class MeetingDbModel(
bannerText: Option[String],
bannerColor: Option[String],
createdTime: Long,
durationInSeconds: Int
durationInSeconds: Int,
endedAt: Option[java.sql.Timestamp],
endedReasonCode: Option[String],
endedBy: Option[String],
)
class MeetingDbTableDef(tag: Tag) extends Table[MeetingDbModel](tag, None, "meeting") {
@ -45,7 +48,10 @@ class MeetingDbTableDef(tag: Tag) extends Table[MeetingDbModel](tag, None, "meet
bannerText,
bannerColor,
createdTime,
durationInSeconds
durationInSeconds,
endedAt,
endedReasonCode,
endedBy
) <> (MeetingDbModel.tupled, MeetingDbModel.unapply)
val meetingId = column[String]("meetingId", O.PrimaryKey)
val extId = column[String]("extId")
@ -64,6 +70,9 @@ 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")
val endedReasonCode = column[Option[String]]("endedReasonCode")
val endedBy = column[Option[String]]("endedBy")
}
object MeetingDAO {
@ -84,39 +93,42 @@ 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,
endedReasonCode = None,
endedBy = 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 +143,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 +154,32 @@ 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, endedReasonCode: String, endedBy: String) = {
UserDAO.softDeleteAllFromMeeting(meetingId)
DatabaseConnection.db.run(
TableQuery[MeetingDbTableDef]
.filter(_.meetingId === meetingId)
.map(a => (a.endedAt, a.endedReasonCode, a.endedBy))
.update(
(
Some(new java.sql.Timestamp(System.currentTimeMillis())),
Some(endedReasonCode),
endedBy match {
case "" => None
case c => Some(c)
}
)
)
).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")
}
}
}

View File

@ -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)

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -7,7 +7,7 @@ import org.bigbluebutton.core.apps.groupchats.GroupChatApp
import org.bigbluebutton.core.apps.users.UsersApp
import org.bigbluebutton.core.apps.voice.VoiceApp
import org.bigbluebutton.core.bus.{BigBlueButtonEvent, InternalEventBus}
import org.bigbluebutton.core.db.{BreakoutRoomUserDAO, MeetingRecordingDAO, UserBreakoutRoomDAO}
import org.bigbluebutton.core.db.{BreakoutRoomUserDAO, MeetingDAO, MeetingRecordingDAO, UserBreakoutRoomDAO}
import org.bigbluebutton.core.domain.{MeetingEndReason, MeetingState2x}
import org.bigbluebutton.core.models._
import org.bigbluebutton.core2.MeetingStatus2x
@ -206,6 +206,8 @@ trait HandlerHelpers extends SystemConfiguration {
val endedEvnt = buildMeetingEndedEvtMsg(liveMeeting.props.meetingProp.intId)
outGW.send(endedEvnt)
MeetingDAO.setMeetingEnded(liveMeeting.props.meetingProp.intId, reason, userId)
}
def destroyMeeting(eventBus: InternalEventBus, meetingId: String): Unit = {

View File

@ -75,6 +75,7 @@ public class MeetingService implements MessageListener {
*/
private final ConcurrentMap<String, Meeting> meetings;
private final ConcurrentMap<String, UserSession> sessions;
private final ConcurrentMap<String, UserSessionBasicData> 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<String, Meeting>(8, 0.9f, 1);
sessions = new ConcurrentHashMap<String, UserSession>(8, 0.9f, 1);
removedSessions = new ConcurrentHashMap<String, UserSessionBasicData>(8, 0.9f, 1);
uploadAuthzTokens = new HashMap<String, PresentationUploadToken>();
}
@ -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<Map.Entry<String, UserSession>> iterator = sessions.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, UserSession> 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<Map.Entry<String, UserSessionBasicData>> iterator = removedSessions.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, UserSessionBasicData> 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<String, Object> 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;
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*
*/
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;
}
}

View File

@ -22,7 +22,7 @@ public class GuestPolicyValidator implements ConstraintValidator<GuestPolicyCons
}
MeetingService meetingService = ServiceUtils.getMeetingService();
UserSession userSession = meetingService.getUserSessionWithAuthToken(sessionToken);
UserSession userSession = meetingService.getUserSessionWithSessionToken(sessionToken);
if(userSession == null || !userSession.guestStatus.equals(GuestPolicy.ALLOW)) {
return false;

View File

@ -19,7 +19,7 @@ public class UserSessionValidator implements ConstraintValidator<UserSessionCons
return false;
}
UserSession userSession = ServiceUtils.getMeetingService().getUserSessionWithAuthToken(sessionToken);
UserSession userSession = ServiceUtils.getMeetingService().getUserSessionWithSessionToken(sessionToken);
if(userSession == null) {
return false;

View File

@ -22,7 +22,7 @@ public class SessionService {
private void getUserSessionWithToken() {
if(sessionToken != null) {
userSession = meetingService.getUserSessionWithAuthToken(sessionToken);
userSession = meetingService.getUserSessionWithSessionToken(sessionToken);
}
}

View File

@ -1,9 +1,6 @@
import { RedisMessage } from '../types';
import { ValidationError } from '../types/ValidationError';
import {throwErrorIfNotPresenter} from "../imports/validation";
export default function buildRedisMessage(sessionVariables: Record<string, unknown>, input: Record<string, unknown>): RedisMessage {
throwErrorIfNotPresenter(sessionVariables);
const eventName = `DeleteWhiteboardAnnotationsPubMsg`;
const routing = {

View File

@ -1,9 +1,6 @@
import { RedisMessage } from '../types';
import { ValidationError } from '../types/ValidationError';
import {throwErrorIfNotPresenter} from "../imports/validation";
export default function buildRedisMessage(sessionVariables: Record<string, unknown>, input: Record<string, unknown>): RedisMessage {
throwErrorIfNotPresenter(sessionVariables);
const eventName = `DeleteWhiteboardAnnotationsPubMsg`;
const routing = {

View File

@ -1,9 +1,7 @@
import { RedisMessage } from '../types';
import { ValidationError } from '../types/ValidationError';
import {throwErrorIfNotPresenter} from "../imports/validation";
export default function buildRedisMessage(sessionVariables: Record<string, unknown>, input: Record<string, unknown>): RedisMessage {
throwErrorIfNotPresenter(sessionVariables);
const eventName = `SendWhiteboardAnnotationsPubMsg`;
const routing = {

View File

@ -0,0 +1,27 @@
import { RedisMessage } from '../types';
import {throwErrorIfNotPresenter} from "../imports/validation";
export default function buildRedisMessage(sessionVariables: Record<string, unknown>, input: Record<string, unknown>): 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 };
}

View File

@ -1,5 +1,4 @@
import { RedisMessage } from '../types';
import { ValidationError } from '../types/ValidationError';
import {throwErrorIfNotPresenter} from "../imports/validation";
export default function buildRedisMessage(sessionVariables: Record<string, unknown>, input: Record<string, unknown>): RedisMessage {

View File

@ -89,6 +89,7 @@ export default function Auth() {
}
meeting {
name
ended
}
}
}`
@ -107,7 +108,12 @@ export default function Auth() {
{data.user_current.map((curr) => {
console.log('user_current', curr);
if(curr.loggedOut) {
if(curr.meeting.ended) {
return <div>
{curr.meeting.name}
<br/><br/>
Meeting has ended.</div>
} else if(curr.ejected) {
return <div>
{curr.meeting.name}
<br/><br/>
@ -139,11 +145,11 @@ export default function Auth() {
<span>You are online, welcome {curr.name} ({curr.userId})</span>
<button onClick={() => handleDispatchUserLeave()}>Leave Now!</button>
{/*<MyInfo userAuthToken={curr.authToken} />*/}
{/*<br />*/}
<MyInfo userAuthToken={curr.authToken} />
<br />
{/*<MeetingInfo />*/}
{/*<br />*/}
<MeetingInfo />
<br />
<TotalOfUsers />
<TotalOfModerators />

View File

@ -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() {
{/*<th>Id</th>*/}
<th>connectionAliveAt</th>
<th>userClientResponseAt</th>
<th>rttInMs</th>
<th>applicationRttInMs</th>
<th>networkRttInMs</th>
<th>status</th>
<th>statusUpdatedAt</th>
</tr>
@ -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() {
<button onClick={() => handleUpdateConnectionAliveAt()}>Update now!</button>
</td>
<td>{curr.userClientResponseAt}</td>
<td>{curr.rttInMs}</td>
<td>{curr.applicationRttInMs}</td>
<td>{curr.networkRttInMs}</td>
<td>{curr.status}</td>
<td>{curr.statusUpdatedAt}</td>
</tr>

View File

@ -33,7 +33,7 @@ export default function UserConnectionStatusReport() {
</thead>
<tbody>
{data.user_connectionStatusReport.map((curr) => {
console.log('user_connectionStatusReport', curr);
//console.log('user_connectionStatusReport', curr);
return (
<tr key={curr.user.userId}>
<td>{curr.user.name}</td>

View File

@ -25,6 +25,7 @@ type GraphQlSubscription struct {
StreamCursorField string
StreamCursorVariableName string
StreamCursorCurrValue interface{}
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
}
@ -42,9 +43,10 @@ type BrowserConnection struct {
}
type HasuraConnection struct {
Id string // hasura connection id
Browserconn *BrowserConnection // browser connection that originated this hasura connection
Websocket *websocket.Conn // websocket used to connect to hasura
Context context.Context // hasura connection context (child of browser connection context)
ContextCancelFunc context.CancelFunc // function to cancel the hasura context (and so, the hasura connection)
Id string // hasura connection id
Browserconn *BrowserConnection // browser connection that originated this hasura connection
Websocket *websocket.Conn // websocket used to connect to hasura
Context context.Context // hasura connection context (child of browser connection context)
ContextCancelFunc context.CancelFunc // function to cancel the hasura context (and so, the hasura connection)
MsgReceivingActiveChan *SafeChannel // indicate that it's waiting for the return of mutations before closing connection
}

View File

@ -60,10 +60,11 @@ func HasuraClient(browserConnection *common.BrowserConnection, cookies []*http.C
defer hasuraConnectionContextCancel()
var thisConnection = common.HasuraConnection{
Id: hasuraConnectionId,
Browserconn: browserConnection,
Context: hasuraConnectionContext,
ContextCancelFunc: hasuraConnectionContextCancel,
Id: hasuraConnectionId,
Browserconn: browserConnection,
Context: hasuraConnectionContext,
ContextCancelFunc: hasuraConnectionContextCancel,
MsgReceivingActiveChan: common.NewSafeChannel(1),
}
browserConnection.HasuraConnection = &thisConnection

View File

@ -2,11 +2,13 @@ package reader
import (
"context"
"encoding/json"
"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"
log "github.com/sirupsen/logrus"
"hash/crc32"
"nhooyr.io/websocket/wsjson"
"sync"
)
@ -35,68 +37,118 @@ func HasuraConnectionReader(hc *common.HasuraConnection, fromHasuraToBrowserChan
log.Tracef("received from hasura: %v", message)
var messageAsMap = message.(map[string]interface{})
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)
if messageMap != nil {
var messageType = messageMap["type"]
var queryId, _ = messageMap["id"].(string)
//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
}
//When Hasura send msg type "complete", this query is finished
if messageType == "complete" {
handleCompleteMessage(hc, queryId)
}
if messageType == "data" &&
subscription.Type == common.Subscription {
hasNoPreviousOccurrence := handleSubscriptionMessage(hc, messageMap, subscription, queryId)
if !hasNoPreviousOccurrence {
return
}
//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)
}
//Apply msg patch when it supports it
if subscription.JsonPatchSupported &&
messageType == "data" &&
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()
}
}
}
// 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)
//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
dataChecksum := crc32.ChecksumIEEE(dataAsJson)
if subscription.LastReceivedDataChecksum == dataChecksum {
return false
}
//Store LastReceivedData Checksum
subscription.LastReceivedDataChecksum = dataChecksum
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)
}

View File

@ -1,13 +1,12 @@
package writer
import (
"github.com/iMDT/bbb-graphql-middleware/internal/msgpatch"
"strings"
"sync"
"github.com/iMDT/bbb-graphql-middleware/internal/common"
"github.com/iMDT/bbb-graphql-middleware/internal/msgpatch"
log "github.com/sirupsen/logrus"
"nhooyr.io/websocket/wsjson"
"strings"
"sync"
)
// HasuraConnectionWriter
@ -39,6 +38,10 @@ RangeLoop:
select {
case <-hc.Context.Done():
break RangeLoop
case <-hc.MsgReceivingActiveChan.ReceiveChannel():
log.Debugf("freezing channel fromBrowserToHasuraChannel")
//Freeze channel once it's about to close Hasura connection
fromBrowserToHasuraChannel.FreezeChannel()
case fromBrowserMessage := <-fromBrowserToHasuraChannel.ReceiveChannel():
{
if fromBrowserMessage == nil {
@ -52,6 +55,7 @@ RangeLoop:
//Identify type based on query string
messageType := common.Query
var lastReceivedDataChecksum uint32
streamCursorField := ""
streamCursorVariableName := ""
var streamCursorInitialValue interface{}
@ -62,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 {
lastReceivedDataChecksum = existingSubscriptionData.LastReceivedDataChecksum
}
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)
@ -107,6 +115,7 @@ RangeLoop:
LastSeenOnHasuraConnection: hc.Id,
JsonPatchSupported: jsonPatchSupported,
Type: messageType,
LastReceivedDataChecksum: lastReceivedDataChecksum,
}
// log.Tracef("Current queries: %v", browserConnection.ActiveSubscriptions)
browserConnection.ActiveSubscriptionsMutex.Unlock()

View File

@ -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)
}
}
}
}

View File

@ -136,16 +136,21 @@ func InvalidateSessionTokenConnections(sessionTokenToInvalidate string) {
for _, browserConnection := range BrowserConnections {
if browserConnection.SessionToken == sessionTokenToInvalidate {
if browserConnection.HasuraConnection != nil {
//Close chan to force stop receiving new messages from the browser
browserConnection.HasuraConnection.MsgReceivingActiveChan.Close()
// Wait until there are no active mutations
for iterationCount := 0; iterationCount < 100; iterationCount++ {
for iterationCount := 0; iterationCount < 20; iterationCount++ {
activeMutationFound := false
browserConnection.ActiveSubscriptionsMutex.RLock()
for _, subscription := range browserConnection.ActiveSubscriptions {
if subscription.Type == common.Mutation {
activeMutationFound = true
break
}
}
browserConnection.ActiveSubscriptionsMutex.RUnlock()
if !activeMutationFound {
break
}

View File

@ -41,7 +41,6 @@ func BrowserConnectionReader(browserConnectionId string, ctx context.Context, c
}
log.Tracef("received from browser: %v", v)
//fmt.Println("received from browser: %v", v)
fromBrowserToHasuraChannel.Send(v)
fromBrowserToHasuraConnectionEstablishingChannel.Send(v)

View File

@ -56,7 +56,7 @@ func StartRedisListener() {
log.Debugf("Received invalidate request for sessionToken %v", sessionTokenToInvalidate)
//Not being used yet
InvalidateSessionTokenConnections(sessionTokenToInvalidate.(string))
go InvalidateSessionTokenConnections(sessionTokenToInvalidate.(string))
}
}
}

View File

@ -10,7 +10,7 @@ import (
"nhooyr.io/websocket/wsjson"
)
func BrowserConnectionWriter(browserConnectionId string, ctx context.Context, c *websocket.Conn, fromHasuratoBrowserChannel *common.SafeChannel, wg *sync.WaitGroup) {
func BrowserConnectionWriter(browserConnectionId string, ctx context.Context, c *websocket.Conn, fromHasuraToBrowserChannel *common.SafeChannel, wg *sync.WaitGroup) {
log := log.WithField("_routine", "BrowserConnectionWriter").WithField("browserConnectionId", browserConnectionId)
defer log.Debugf("finished")
log.Debugf("starting")
@ -21,7 +21,7 @@ RangeLoop:
select {
case <-ctx.Done():
break RangeLoop
case toBrowserMessage := <-fromHasuratoBrowserChannel.ReceiveChannel():
case toBrowserMessage := <-fromHasuraToBrowserChannel.ReceiveChannel():
{
var toBrowserMessageAsMap = toBrowserMessage.(map[string]interface{})

View File

@ -25,14 +25,16 @@ create table "meeting" (
"bannerText" text,
"bannerColor" varchar(50),
"createdTime" bigint,
"durationInSeconds" integer
"durationInSeconds" integer,
"endedAt" timestamp with time zone,
"endedReasonCode" varchar(200),
"endedBy" varchar(50)
);
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");
create view "v_meeting" as select * from "meeting";
create table "meeting_breakout" (
"meetingId" varchar(100) primary key references "meeting"("meetingId") ON DELETE CASCADE,
"parentId" varchar(100),
@ -428,6 +430,13 @@ AS SELECT "user"."userId",
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";
--This view will be used by Meteor to validate if the provided authToken is valid
--It is temporary while Meteor is not removed
create view "v_user_connection_auth" as
select "meetingId", "userId", "authToken"
from "v_user_current"
where "isOnline" is true;
CREATE OR REPLACE VIEW "v_user_guest" AS
SELECT u."meetingId", u."userId",
u."guestStatus",
@ -781,6 +790,14 @@ JOIN "user_reaction" ur ON u."userId" = ur."userId" AND "expiresAt" > current_ti
GROUP BY u."meetingId", ur."userId";
create view "v_meeting" as
select "meeting".*, "user_ended"."name" as "endedByUserName"
from "meeting"
left join "user" "user_ended" on "user_ended"."userId" = "meeting"."endedBy"
;
-- ===================== CHAT TABLES

View File

@ -18,6 +18,22 @@ sudo -u postgres psql -U postgres -d bbb_graphql -a -f bbb_schema.sql --set ON_E
sudo -u postgres psql -c "drop database if exists hasura_app with (force)"
sudo -u postgres psql -c "create database hasura_app"
echo "Creating frontend in bbb_graphql"
DATABASE_FRONTEND_USER="bbb_frontend"
FRONT_USER_EXISTS=$(sudo -u postgres psql -U postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname = '$DATABASE_FRONTEND_USER'")
if [ "$FRONT_USER_EXISTS" = '1' ]
then
echo "User $DATABASE_FRONTEND_USER already exists"
else
sudo -u postgres psql -q -c "CREATE USER $DATABASE_FRONTEND_USER WITH PASSWORD '$DATABASE_FRONTEND_USER'"
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"
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!"
@ -39,7 +55,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

View File

@ -280,6 +280,13 @@ type Mutation {
): Boolean
}
type Mutation {
presentationExport(
presentationId: String!
fileStateType: String!
): Boolean
}
type Mutation {
presentationRemove(
presentationId: String!

View File

@ -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
@ -382,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:

View File

@ -145,6 +145,11 @@ select_permissions:
- customLogoUrl
- disabledFeatures
- durationInSeconds
- ended
- endedAt
- endedBy
- endedByUserName
- endedReasonCode
- extId
- html5InstanceId
- isBreakout
@ -159,12 +164,17 @@ select_permissions:
filter:
meetingId:
_eq: X-Hasura-MeetingId
- role: pre_join_bbb_client
- role: not_joined_bbb_client
permission:
columns:
- bannerColor
- bannerText
- customLogoUrl
- ended
- endedAt
- endedBy
- endedByUserName
- endedReasonCode
- logoutUrl
- meetingId
- name

View File

@ -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: ""

View File

@ -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

View File

@ -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

View File

@ -24,6 +24,22 @@ sudo -u postgres psql -c "alter database bbb_graphql set timezone to 'UTC'"
echo "Creating tables in bbb_graphql"
sudo -u postgres psql -U postgres -d bbb_graphql -q -f bbb_schema.sql --set ON_ERROR_STOP=on
echo "Creating frontend in bbb_graphql"
DATABASE_FRONTEND_USER="bbb_frontend"
FRONT_USER_EXISTS=$(sudo -u postgres psql -U postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname = '$DATABASE_FRONTEND_USER'")
if [ "$FRONT_USER_EXISTS" = '1' ]
then
echo "User $DATABASE_FRONTEND_USER already exists"
else
sudo -u postgres psql -q -c "CREATE USER $DATABASE_FRONTEND_USER WITH PASSWORD '$DATABASE_FRONTEND_USER'"
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"
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"
sudo systemctl start bbb-graphql-server

View File

@ -1 +0,0 @@
import './methods';

View File

@ -1,12 +0,0 @@
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,
});

View File

@ -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}`);
}
}

View File

@ -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}`);
}
}

View File

@ -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}`);
}
}

View File

@ -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}`);
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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);

View File

@ -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);
}

View File

@ -1,2 +0,0 @@
import './eventHandlers';
import './publishers';

View File

@ -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}`);
}
}
}

View File

@ -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}`);
}
}

View File

@ -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);

View File

@ -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),

View File

@ -1 +0,0 @@
import './methods';

View File

@ -1,12 +0,0 @@
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,
});

View File

@ -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}`);
}
}

View File

@ -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}`);
}
}

View File

@ -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}`);
}
}

View File

@ -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}`);
}
}

View File

@ -1 +0,0 @@
import './methods';

View File

@ -1,6 +0,0 @@
import { Meteor } from 'meteor/meteor';
import switchSlide from './methods/switchSlide';
Meteor.methods({
switchSlide,
});

View File

@ -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}`);
}
}

View File

@ -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,

View File

@ -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';

View File

@ -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';

View File

@ -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 = (
<Styled.ClosedCaptionToggleButton
icon={active ? 'closed_caption' : 'closed_caption_stop'}
label={intl.formatMessage(active ? intlMessages.stop : intlMessages.start)}
color={active ? 'primary' : 'default'}
ghost={!active}
hideLabel
circle
size="lg"
onClick={onToggleClick}
/>
);
return (
shouldRenderChevron
? (
<Styled.SpanButtonWrapper>
<BBBMenu
trigger={(
<>
{ startStopCaptionsButton }
<ButtonEmoji
emoji="device_list_selector"
hideLabel
label={intl.formatMessage(intlMessages.transcriptionSettings)}
tabIndex={0}
rotate
/>
</>
)}
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' },
}}
/>
</Styled.SpanButtonWrapper>
) : 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);

View File

@ -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) => <Button {...props} />;
withTracker(() => {
const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
const availableVoices = SpeechService.getSpeechVoices();
const currentSpeechLocale = SpeechService.getSpeechLocale();
const isSupported = availableVoices.length > 0;
const isVoiceUser = AudioService.isVoiceUser();
return {
isRTL,
enabled: Service.hasAudioCaptions(),
active: Service.getAudioCaptions(),
currentSpeechLocale,
availableVoices,
isSupported,
isVoiceUser,
};
})(Container);
export default AudioCaptionsButtonContainer;

View File

@ -1,61 +0,0 @@
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';
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,
};

View File

@ -1,104 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import UserContainer from './user/container';
const CAPTIONS_CONFIG = Meteor.settings.public.captions;
class LiveCaptions extends PureComponent {
constructor(props) {
super(props);
this.state = { clear: true };
this.timer = null;
}
componentDidUpdate(prevProps) {
const { clear } = this.state;
if (clear) {
const { transcript } = this.props;
if (prevProps.transcript !== transcript) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ clear: false });
}
} else {
this.resetTimer();
this.timer = setTimeout(() => this.setState({ clear: true }), CAPTIONS_CONFIG.time);
}
}
componentWillUnmount() {
this.resetTimer();
}
resetTimer() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
render() {
const {
transcript,
transcriptId,
} = this.props;
const { clear } = this.state;
const hasContent = transcript.length > 0 && !clear;
const wrapperStyles = {
display: 'flex',
};
const captionStyles = {
whiteSpace: 'pre-line',
wordWrap: 'break-word',
fontFamily: 'Verdana, Arial, Helvetica, sans-serif',
fontSize: '1.5rem',
background: '#000000a0',
color: 'white',
padding: hasContent ? '.5rem' : undefined,
};
const visuallyHidden = {
position: 'absolute',
overflow: 'hidden',
clip: 'rect(0 0 0 0)',
height: '1px',
width: '1px',
margin: '-1px',
padding: '0',
border: '0',
};
return (
<div style={wrapperStyles}>
{clear ? null : (
<UserContainer
background="#000000a0"
transcriptId={transcriptId}
/>
)}
<div style={captionStyles}>
{clear ? '' : transcript}
</div>
<div
style={visuallyHidden}
aria-atomic
aria-live="polite"
>
{clear ? '' : transcript}
</div>
</div>
);
}
}
LiveCaptions.propTypes = {
transcript: PropTypes.string.isRequired,
transcriptId: PropTypes.string.isRequired,
};
export default LiveCaptions;

View File

@ -1,21 +0,0 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import Service from '/imports/ui/components/audio/captions/service';
import LiveCaptions from './component';
import AudioCaptionsLiveContainer from '../../audio-graphql/audio-captions/live/component';
const Container = (props) => <LiveCaptions {...props} />;
withTracker(() => {
const {
transcriptId,
transcript,
} = Service.getAudioCaptionsData();
return {
transcript,
transcriptId,
};
})(Container);
export default AudioCaptionsLiveContainer;

View File

@ -1,39 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import UserAvatar from '/imports/ui/components/user-avatar/component';
const User = ({
avatar,
background,
color,
moderator,
name,
}) => (
<div
style={{
background,
minHeight: '3.25rem',
padding: '.5rem',
textTransform: 'capitalize',
width: '3.25rem',
}}
>
<UserAvatar
avatar={avatar}
color={color}
moderator={moderator}
>
{name.slice(0, 2)}
</UserAvatar>
</div>
);
User.propTypes = {
avatar: PropTypes.string.isRequired,
background: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
moderator: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
};
export default User;

View File

@ -1,44 +0,0 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import Users from '/imports/api/users';
import User from './component';
const MODERATOR = Meteor.settings.public.user.role_moderator;
const Container = (props) => <User {...props} />;
const getUser = (userId) => {
const user = Users.findOne(
{ userId },
{
fields: {
avatar: 1,
color: 1,
role: 1,
name: 1,
},
},
);
if (user) {
return {
avatar: user.avatar,
color: user.color,
moderator: user.role === MODERATOR,
name: user.name,
};
}
return {
avatar: '',
color: '',
moderator: false,
name: '',
};
};
export default withTracker(({ transcriptId }) => {
const userId = transcriptId.split('-')[0];
return getUser(userId);
})(Container);

View File

@ -1,38 +1,8 @@
import AudioCaptions from '/imports/api/audio-captions';
import Auth from '/imports/ui/services/auth';
const getAudioCaptionsData = () => {
const audioCaptions = AudioCaptions.findOne({ meetingId: Auth.meetingID });
if (audioCaptions) {
return {
transcriptId: audioCaptions.transcriptId,
transcript: audioCaptions.transcript,
};
}
return {
transcriptId: '',
transcript: '',
};
};
const getAudioCaptions = () => Session.get('audioCaptions') || false;
const setAudioCaptions = (value) => Session.set('audioCaptions', value);
const hasAudioCaptions = () => {
const audioCaptions = AudioCaptions.findOne(
{ meetingId: Auth.meetingID },
{ fields: {} },
);
return !!audioCaptions;
};
export default {
getAudioCaptionsData,
getAudioCaptions,
setAudioCaptions,
hasAudioCaptions,
};

View File

@ -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<ChatPollContentProps> = ({

View File

@ -5,6 +5,8 @@ import audioManager from '/imports/ui/services/audio-manager';
import { Session } from 'meteor/session';
import { useReactiveVar, useMutation } from '@apollo/client';
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
import { ExternalVideoVolumeCommandsEnum } from 'bigbluebutton-html-plugin-sdk/dist/cjs/ui-commands/external-video/volume/enums';
import { SetExternalVideoVolumeCommandArguments } from 'bigbluebutton-html-plugin-sdk/dist/cjs/ui-commands/external-video/volume/types';
import { OnProgressProps } from 'react-player/base';
import useMeeting from '/imports/ui/core/hooks/useMeeting';
@ -217,6 +219,14 @@ const ExternalVideoPlayer: React.FC<ExternalVideoPlayerProps> = ({
type: ACTIONS.SET_PRESENTATION_IS_OPEN,
value: true,
});
const handleExternalVideoVolumeSet = ((
event: CustomEvent<SetExternalVideoVolumeCommandArguments>,
) => setVolume(event.detail.volume)) as EventListener;
window.addEventListener(ExternalVideoVolumeCommandsEnum.SET, handleExternalVideoVolumeSet);
return () => {
window.addEventListener(ExternalVideoVolumeCommandsEnum.SET, handleExternalVideoVolumeSet);
};
}, []);
useEffect(() => {

View File

@ -4,8 +4,6 @@ import { defineMessages, useIntl } from 'react-intl';
import {
IsBreakoutSubscriptionData,
MEETING_ISBREAKOUT_SUBSCRIPTION,
TALKING_INDICATOR_SUBSCRIPTION,
TalkingIndicatorSubscriptionData,
} from './queries';
import { UserVoice } from '/imports/ui/Types/userVoice';
import { uniqueId } from '/imports/utils/string-utils';
@ -13,6 +11,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 useTalkingIndicator from '/imports/ui/core/hooks/useTalkingIndicator';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - temporary, while meteor exists in the project
@ -178,15 +177,8 @@ const TalkingIndicatorContainer: React.FC = (() => {
const {
data: talkingIndicatorData,
loading: talkingIndicatorLoading,
error: talkingIndicatorError,
} = useSubscription<TalkingIndicatorSubscriptionData>(
TALKING_INDICATOR_SUBSCRIPTION,
{
variables: {
limit: TALKING_INDICATORS_MAX,
},
},
);
errors: talkingIndicatorError,
} = useTalkingIndicator((c) => c);
const {
data: isBreakoutData,
@ -205,7 +197,7 @@ const TalkingIndicatorContainer: React.FC = (() => {
);
}
const talkingUsers = talkingIndicatorData?.user_voice ?? [];
const talkingUsers = talkingIndicatorData ?? [];
const isBreakout = isBreakoutData?.meeting[0]?.isBreakout ?? false;
return (

View File

@ -1,43 +1,14 @@
import { gql } from '@apollo/client';
import { UserVoice } from '/imports/ui/Types/userVoice';
interface IsBreakoutData {
meetingId: string;
isBreakout: boolean;
}
export interface TalkingIndicatorSubscriptionData {
// eslint-disable-next-line camelcase
user_voice: Array<Partial<UserVoice>>;
}
export interface IsBreakoutSubscriptionData {
meeting: Array<IsBreakoutData>;
}
export const TALKING_INDICATOR_SUBSCRIPTION = gql`
subscription TalkingIndicatorSubscription($limit: Int!) {
user_voice(
where: { showTalkingIndicator: { _eq: true } }
order_by: [{ startTime: desc_nulls_last }, { endTime: desc_nulls_last }]
limit: $limit
) {
callerName
spoke
talking
floor
startTime
muted
userId
user {
color
name
speechLocale
}
}
}
`;
// TODO: rework when useMeeting hook be avaible
export const MEETING_ISBREAKOUT_SUBSCRIPTION = gql`
subscription getIsBreakout {

View File

@ -12,6 +12,7 @@ import { SMALL_VIEWPORT_BREAKPOINT } from '../../layout/enums';
import { PluginsContext } from '/imports/ui/components/components-data/plugin-context/context';
import { USER_LEAVE_MEETING } from '/imports/ui/core/graphql/mutations/userMutations';
import { useMutation } from '@apollo/client';
import useMeeting from '/imports/ui/core/hooks/useMeeting';
const { isIphone } = deviceInfo;
const { isSafari, isValidSafariVersion } = browserInfo;
@ -30,11 +31,22 @@ const OptionsDropdownContainer = (props) => {
];
}
const {
data: currentMeeting,
} = useMeeting((m) => {
return {
componentsFlags: m.componentsFlags,
};
});
const componentsFlags = currentMeeting?.componentsFlags;
const audioCaptionsEnabled = componentsFlags?.hasCaption;
const [userLeaveMeeting] = useMutation(USER_LEAVE_MEETING);
return (
<OptionsDropdown {...{
isMobile, isRTL, optionsDropdownItems, userLeaveMeeting, ...props,
isMobile, isRTL, optionsDropdownItems, userLeaveMeeting, audioCaptionsEnabled, ...props,
}}
/>
);
@ -44,7 +56,6 @@ export default withTracker((props) => {
const handleToggleFullscreen = () => FullscreenService.toggleFullScreen();
return {
amIModerator: props.amIModerator,
audioCaptionsEnabled: audioCaptionsService.hasAudioCaptions(),
audioCaptionsActive: audioCaptionsService.getAudioCaptions(),
audioCaptionsSet: (value) => audioCaptionsService.setAudioCaptions(value),
isMobile: deviceInfo.isMobile,

View File

@ -51,6 +51,8 @@ class NotesDropdown extends PureComponent {
intl,
amIPresenter,
presentations,
setPresentation,
removePresentation,
stopExternalVideoShare,
} = this.props;
@ -72,7 +74,7 @@ class NotesDropdown extends PureComponent {
onClick: () => {
this.setConverterButtonDisabled(true);
setTimeout(() => this.setConverterButtonDisabled(false), DEBOUNCE_TIMEOUT);
return Service.convertAndUpload(presentations);
return Service.convertAndUpload(presentations, setPresentation, removePresentation);
},
},
);

View File

@ -6,6 +6,7 @@ import {
PROCESSED_PRESENTATIONS_SUBSCRIPTION,
} from '/imports/ui/components/whiteboard/queries';
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
import { PRESENTATION_SET_CURRENT, PRESENTATION_REMOVE } from '../../presentation/mutations';
import { EXTERNAL_VIDEO_STOP } from '../../external-video-player/mutations';
const NotesDropdownContainer = ({ ...props }) => {
@ -18,8 +19,18 @@ const NotesDropdownContainer = ({ ...props }) => {
const { data: presentationData } = useSubscription(PROCESSED_PRESENTATIONS_SUBSCRIPTION);
const presentations = presentationData?.pres_presentation || [];
const [presentationSetCurrent] = useMutation(PRESENTATION_SET_CURRENT);
const [presentationRemove] = useMutation(PRESENTATION_REMOVE);
const [stopExternalVideoShare] = useMutation(EXTERNAL_VIDEO_STOP);
const setPresentation = (presentationId) => {
presentationSetCurrent({ variables: { presentationId } });
};
const removePresentation = (presentationId) => {
presentationRemove({ variables: { presentationId } });
};
return (
<NotesDropdown
{
@ -27,6 +38,8 @@ const NotesDropdownContainer = ({ ...props }) => {
amIPresenter,
isRTL,
presentations,
setPresentation,
removePresentation,
stopExternalVideoShare,
...props,
}

View File

@ -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, removePresentation) {
let filename = 'Shared_Notes';
const duplicates = presentations.filter((pres) => pres.filename?.startsWith(filename) || pres.name?.startsWith(filename)).length;
@ -53,7 +52,9 @@ async function convertAndUpload(presentations) {
onUpload: () => { },
onProgress: () => { },
onDone: () => { },
});
},
setPresentation,
removePresentation);
}
export default {

View File

@ -2,13 +2,17 @@ import React, { useEffect } from 'react';
import { useMutation, useSubscription } from '@apollo/client';
import * as PluginSdk from 'bigbluebutton-html-plugin-sdk';
import { createChannelIdentifier } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-channel/hooks';
import { createChannelIdentifier } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-channel/utils';
import {
DataChannelArguments,
DispatcherFunction, ObjectTo, ToRole, ToUserId,
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-channel/types';
import { DataChannelHooks } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-channel/enums';
import { HookEvents } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
import { HookEventWrapper, UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import { PLUGIN_DATA_CHANNEL_DISPATCH_QUERY, PLUGIN_DATA_CHANNEL_FETCH_QUERY } from '../queries';
import PLUGIN_DATA_CHANNEL_FETCH_QUERY from '../queries';
import { PLUGIN_DATA_CHANNEL_DELETE_MUTATION, PLUGIN_DATA_CHANNEL_DISPATCH_MUTATION, PLUGIN_DATA_CHANNEL_RESET_MUTATION } from '../mutation';
export interface DataChannelItemManagerProps {
pluginName: string;
@ -35,7 +39,9 @@ export const DataChannelItemManager: React.ElementType<DataChannelItemManagerPro
const pluginIdentifier = createChannelIdentifier(channelName, pluginName);
const dataChannelIdentifier = createChannelIdentifier(channelName, pluginName);
const [dispatchPluginDataChannelMessage] = useMutation(PLUGIN_DATA_CHANNEL_DISPATCH_QUERY);
const [dispatchPluginDataChannelMessage] = useMutation(PLUGIN_DATA_CHANNEL_DISPATCH_MUTATION);
const [deletePluginDataChannelMessage] = useMutation(PLUGIN_DATA_CHANNEL_DELETE_MUTATION);
const [resetPluginDataChannelMessage] = useMutation(PLUGIN_DATA_CHANNEL_RESET_MUTATION);
const data = useSubscription(PLUGIN_DATA_CHANNEL_FETCH_QUERY, {
variables: {
@ -81,12 +87,44 @@ export const DataChannelItemManager: React.ElementType<DataChannelItemManagerPro
pluginApi.mapOfDispatchers[pluginIdentifier] = useDataChannelHandlerFunction;
window.dispatchEvent(new Event(`${pluginIdentifier}::dispatcherFunction`));
const deleteOrResetHandler: EventListener = (
(event: HookEventWrapper<void>) => {
if (event.detail.hook === DataChannelHooks.DATA_CHANNEL_DELETE) {
const eventDetails = event.detail as UpdatedEventDetails<string>;
const hookArguments = eventDetails?.hookArguments as DataChannelArguments | undefined;
deletePluginDataChannelMessage({
variables: {
pluginName: hookArguments?.pluginName,
dataChannel: hookArguments?.channelName,
messageId: eventDetails.data,
},
});
} else if (event.detail.hook === DataChannelHooks.DATA_CHANNEL_RESET) {
const eventDetails = event.detail as UpdatedEventDetails<void>;
const hookArguments = eventDetails?.hookArguments as DataChannelArguments | undefined;
resetPluginDataChannelMessage({
variables: {
pluginName: hookArguments?.pluginName,
dataChannel: hookArguments?.channelName,
},
});
}
}) as EventListener;
useEffect(() => {
window.dispatchEvent(
new CustomEvent(dataChannelIdentifier, {
detail: { hook: DataChannelHooks.DATA_CHANNEL, data },
detail: { hook: DataChannelHooks.DATA_CHANNEL_BUILDER, data },
}),
);
}, [data]);
useEffect(() => {
window.addEventListener(HookEvents.UPDATED, deleteOrResetHandler);
return () => {
window.removeEventListener(HookEvents.UPDATED, deleteOrResetHandler);
};
}, []);
return null;
};

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { createChannelIdentifier } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-channel/hooks';
import { createChannelIdentifier } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-channel/utils';
import { DataChannelArguments } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-channel/types';
import {
HookEventWrapper, UnsubscribedEventDetails, SubscribedEventDetails,
@ -42,7 +42,7 @@ const PluginDataChannelManager: React.ElementType<PluginDataChannelManagerProps>
useEffect(() => {
const subscribeHandler: EventListener = (
(event: HookEventWrapper<void>) => {
if (event.detail.hook === DataChannelHooks.DATA_CHANNEL) {
if (event.detail.hook === DataChannelHooks.DATA_CHANNEL_BUILDER) {
const eventDetails = event.detail as SubscribedEventDetails;
const hookArguments = eventDetails?.hookArguments as DataChannelArguments | undefined;
if (hookArguments?.channelName && hookArguments?.pluginName) {
@ -52,7 +52,7 @@ const PluginDataChannelManager: React.ElementType<PluginDataChannelManagerProps>
}) as EventListener;
const unsubscribeHandler: EventListener = (
(event: HookEventWrapper<void>) => {
if (event.detail.hook === DataChannelHooks.DATA_CHANNEL) {
if (event.detail.hook === DataChannelHooks.DATA_CHANNEL_BUILDER) {
const eventDetails = event.detail as UnsubscribedEventDetails;
const hookArguments = eventDetails?.hookArguments as DataChannelArguments | undefined;
if (hookArguments?.channelName && hookArguments?.pluginName) {

View File

@ -0,0 +1,34 @@
import { gql } from '@apollo/client';
export const PLUGIN_DATA_CHANNEL_DISPATCH_MUTATION = gql`
mutation PluginDataChannelDispatchMessage($pluginName: String!,
$dataChannel: String!, $payloadJson: String!, $toRoles: [String]!,
$toUserIds: [String]!) {
pluginDataChannelDispatchMessage(
pluginName: $pluginName,
dataChannel: $dataChannel,
payloadJson: $payloadJson,
toRoles: $toRoles,
toUserIds: $toUserIds,
)
}
`;
export const PLUGIN_DATA_CHANNEL_RESET_MUTATION = gql`
mutation PluginDataChannelReset($pluginName: String!, $dataChannel: String!) {
pluginDataChannelReset(
pluginName: $pluginName,
dataChannel: $dataChannel
)
}
`;
export const PLUGIN_DATA_CHANNEL_DELETE_MUTATION = gql`
mutation PluginDataChannelDeleteMessage($pluginName: String!, $dataChannel: String!, $messageId: String!) {
pluginDataChannelDeleteMessage(
pluginName: $pluginName,
dataChannel: $dataChannel,
messageId: $messageId
)
}
`;

View File

@ -1,18 +1,5 @@
import { gql } from '@apollo/client';
const PLUGIN_DATA_CHANNEL_DISPATCH_QUERY = gql`
mutation DispatchPluginDataChannelMessageMsg($pluginName: String!,
$dataChannel: String!, $payloadJson: String!, $toRoles: [String]!, $toUserIds: [String]!) {
dispatchPluginDataChannelMessageMsg(
pluginName: $pluginName,
dataChannel: $dataChannel,
payloadJson: $payloadJson,
toRoles: $toRoles,
toUserIds: $toUserIds,
)
}
`;
const PLUGIN_DATA_CHANNEL_FETCH_QUERY = gql`
subscription FetchPluginDataChannelMessageMsg($pluginName: String!, $channelName: String!){
pluginDataChannelMessage(
@ -33,4 +20,4 @@ const PLUGIN_DATA_CHANNEL_FETCH_QUERY = gql`
}
`;
export { PLUGIN_DATA_CHANNEL_DISPATCH_QUERY, PLUGIN_DATA_CHANNEL_FETCH_QUERY };
export default PLUGIN_DATA_CHANNEL_FETCH_QUERY;

View File

@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import * as PluginSdk from 'bigbluebutton-html-plugin-sdk';
import {
HookEvents,
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
import { DataConsumptionHooks } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/enums';
import { UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook';
import formatTalkingIndicatorDataFromGraphql from './utils';
import { UserVoice } from '/imports/ui/Types/userVoice';
import useTalkingIndicator from '/imports/ui/core/hooks/useTalkingIndicator';
const TalkingIndicatorHookContainer = () => {
const [sendSignal, setSendSignal] = useState(false);
const userVoice: GraphqlDataHookSubscriptionResponse<Partial<UserVoice>[]> = useTalkingIndicator(
(uv: Partial<UserVoice>) => ({
talking: uv.talking,
startTime: uv.startTime,
muted: uv.muted,
userId: uv.userId,
}) as Partial<UserVoice>,
);
const updateTalkingIndicatorForPlugin = () => {
window.dispatchEvent(new CustomEvent<
UpdatedEventDetails<PluginSdk.GraphqlResponseWrapper<PluginSdk.UserVoice>>
>(HookEvents.UPDATED, {
detail: {
data: formatTalkingIndicatorDataFromGraphql(userVoice),
hook: DataConsumptionHooks.TALKING_INDICATOR,
},
}));
};
useEffect(() => {
updateTalkingIndicatorForPlugin();
}, [userVoice, sendSignal]);
useEffect(() => {
const updateHookUseTalkingIndicator = () => {
setSendSignal(!sendSignal);
};
window.addEventListener(
HookEvents.SUBSCRIBED, updateHookUseTalkingIndicator,
);
return () => {
window.removeEventListener(
HookEvents.SUBSCRIBED, updateHookUseTalkingIndicator,
);
};
}, []);
return null;
};
export default TalkingIndicatorHookContainer;

View File

@ -0,0 +1,18 @@
import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook';
import { UserVoice } from '/imports/ui/Types/userVoice';
import * as PluginSdk from 'bigbluebutton-html-plugin-sdk';
const formatTalkingIndicatorDataFromGraphql = (
responseDataFromGraphql: GraphqlDataHookSubscriptionResponse<Partial<UserVoice>[]>,
) => ({
data: !responseDataFromGraphql.loading ? responseDataFromGraphql.data?.map((userVoice) => ({
talking: userVoice.talking,
startTime: userVoice.startTime,
muted: userVoice.muted,
userId: userVoice.userId,
}) as PluginSdk.UserVoice) : undefined,
loading: responseDataFromGraphql.loading,
error: responseDataFromGraphql.errors?.[0],
} as PluginSdk.GraphqlResponseWrapper<PluginSdk.UserVoice>);
export default formatTalkingIndicatorDataFromGraphql;

View File

@ -17,10 +17,12 @@ import CustomSubscriptionHookContainer from './domain/shared/custom-subscription
import { ObjectToCustomHookContainerMap, HookWithArgumentsContainerProps, HookWithArgumentContainerToRender } from './domain/shared/custom-subscription/types';
import CurrentPresentationHookContainer from './domain/presentations/current-presentation/hook-manager';
import LoadedChatMessagesHookContainer from './domain/chat/loaded-chat-messages/hook-manager';
import TalkingIndicatorHookContainer from './domain/user-voice/talking-indicator/hook-manager';
const hooksMap:{
[key: string]: React.FunctionComponent
} = {
[DataConsumptionHooks.TALKING_INDICATOR]: TalkingIndicatorHookContainer,
[DataConsumptionHooks.LOADED_CHAT_MESSAGES]: LoadedChatMessagesHookContainer,
[DataConsumptionHooks.LOADED_USER_LIST]: LoadedUserListHookContainer,
[DataConsumptionHooks.CURRENT_USER]: CurrentUserHookContainer,

View File

@ -14,7 +14,6 @@ import {
import {
colorText,
colorBlueLight,
colorGray,
colorGrayLight,
colorGrayLighter,
colorGrayLightest,
@ -23,8 +22,6 @@ import {
colorHeading,
colorPrimary,
colorGrayDark,
colorWhite,
pollBlue,
} from '/imports/ui/stylesheets/styled-components/palette';
import { fontSizeBase, fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography';
@ -260,71 +257,7 @@ const NoSlidePanelContainer = styled.div`
text-align: center;
`;
const PollButton = styled(Button)`
margin-top: ${smPaddingY};
margin-bottom: ${smPaddingY};
background-color: ${colorWhite};
box-shadow: 0 0 0 1px ${colorGray};
color: ${colorGray};
& > span {
color: ${colorGray};
}
& > span:hover {
color: ${pollBlue};
opacity: 1;
}
&:active {
background-color: ${colorWhite};
box-shadow: 0 0 0 1px ${pollBlue};
& > span {
color: ${pollBlue};
}
}
&:focus {
background-color: ${colorWhite};
box-shadow: 0 0 0 1px ${pollBlue};
& > span {
color: ${pollBlue};
}
}
&:nth-child(even) {
margin-right: inherit;
margin-left: ${smPaddingY};
[dir="rtl"] & {
margin-right: ${smPaddingY};
margin-left: inherit;
}
}
&:nth-child(odd) {
margin-right: 1rem;
margin-left: inherit;
[dir="rtl"] & {
margin-right: inherit;
margin-left: ${smPaddingY};
}
}
&:hover {
box-shadow: 0 0 0 1px ${pollBlue};
background-color: ${colorWhite};
color: ${pollBlue};
& > span {
color: ${pollBlue};
opacity: 1;
}
}
`;
const PollButton = styled(Button)``;
const DragAndDropPollContainer = styled.div`
width: 200px !important;

View File

@ -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"
>

View File

@ -38,7 +38,7 @@ const ButtonWrapper = styled.div`
background-color: ${colorTransparent};
cursor: pointer;
border: 0;
z-index: 2;
z-index: 999;
margin: 2px;
bottom: 0;

View File

@ -23,7 +23,81 @@ export const PRESENTATION_SET_WRITERS = gql`
}
`;
export const PRESENTATION_SET_PAGE = gql`
mutation PresentationSetPage($presentationId: String!, $pageId: String!) {
presentationSetPage(
presentationId: $presentationId,
pageId: $pageId,
)
}
`;
export const PRESENTATION_SET_DOWNLOADABLE = gql`
mutation PresentationSetDownloadable(
$presentationId: String!,
$downloadable: Boolean!,
$fileStateType: String!,) {
presentationSetDownloadable(
presentationId: $presentationId,
downloadable: $downloadable,
fileStateType: $fileStateType,
)
}
`;
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(
presentationId: $presentationId,
)
}
`;
export const PRESENTATION_REMOVE = gql`
mutation PresentationRemove($presentationId: String!) {
presentationRemove(
presentationId: $presentationId,
)
}
`;
export const PRES_ANNOTATION_DELETE = gql`
mutation PresAnnotationDelete($pageId: String!, $annotationsIds: [String]!) {
presAnnotationDelete(
pageId: $pageId,
annotationsIds: $annotationsIds,
)
}
`;
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,
PRESENTATION_SET_PAGE,
PRESENTATION_SET_DOWNLOADABLE,
PRESENTATION_EXPORT,
PRESENTATION_SET_CURRENT,
PRESENTATION_REMOVE,
PRES_ANNOTATION_DELETE,
PRES_ANNOTATION_SUBMIT,
};

View File

@ -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;
@ -358,7 +358,7 @@ const PresentationMenu = (props) => {
);
}
const tools = document.querySelector('#TD-Tools');
const tools = document.querySelector('.tlui-toolbar, .tlui-style-panel__wrapper, .tlui-menu-zone');
if (tools && (props.hasWBAccess || props.amIPresenter)){
menuItems.push(
{

View File

@ -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) {

View File

@ -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(),
};

View File

@ -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,
};

View File

@ -583,6 +583,8 @@ class PresentationUploader extends Component {
selectedToBeNextCurrent,
presentations: propPresentations,
dispatchChangePresentationDownloadable,
setPresentation,
removePresentation,
} = this.props;
const { disableActions, presentations } = this.state;
const presentationsToSave = presentations;
@ -610,7 +612,14 @@ class PresentationUploader extends Component {
if (!disableActions) {
Session.set('showUploadPresentationView', false);
return handleSave(presentationsToSave, true, {}, propPresentations)
return handleSave(
presentationsToSave,
true,
{},
propPresentations,
setPresentation,
removePresentation,
)
.then(() => {
const hasError = presentations.some((p) => !!p.uploadErrorMsgKey);
if (!hasError) {
@ -832,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)

View File

@ -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,16 @@ 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,
PRESENTATION_EXPORT,
PRESENTATION_SET_CURRENT,
PRESENTATION_REMOVE,
} from '../mutations';
const PRESENTATION_CONFIG = Meteor.settings.public.presentation;
@ -31,8 +36,36 @@ const PresentationUploaderContainer = (props) => {
const presentations = presentationData?.pres_presentation || [];
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) => {
makeCall('exportPresentation', presentationId, fileStateType);
presentationExport({
variables: {
presentationId,
fileStateType,
},
});
};
const dispatchChangePresentationDownloadable = (presentationId, downloadable, fileStateType) => {
presentationSetDownloadable({
variables: {
presentationId,
downloadable,
fileStateType,
},
});
};
const setPresentation = (presentationId) => {
presentationSetCurrent({ variables: { presentationId } });
};
const removePresentation = (presentationId) => {
presentationRemove({ variables: { presentationId } });
};
return userIsPresenter && (
@ -42,6 +75,9 @@ const PresentationUploaderContainer = (props) => {
presentations={presentations}
currentPresentation={currentPresentation}
exportPresentation={exportPresentation}
dispatchChangePresentationDownloadable={dispatchChangePresentationDownloadable}
setPresentation={setPresentation}
removePresentation={removePresentation}
{...props}
/>
</ErrorBoundary>
@ -52,7 +88,6 @@ export default withTracker(() => {
const {
dispatchDisableDownloadable,
dispatchEnableDownloadable,
dispatchChangePresentationDownloadable,
} = Service;
const isOpen = isPresentationEnabled() && (Session.get('showUploadPresentationView') || false);
@ -70,7 +105,6 @@ export default withTracker(() => {
renderPresentationItemStatus: PresUploaderToast.renderPresentationItemStatus,
dispatchDisableDownloadable,
dispatchEnableDownloadable,
dispatchChangePresentationDownloadable,
isOpen,
selectedToBeNextCurrent: Session.get('selectedToBeNextCurrent') || null,
externalUploadData: Service.getExternalUploadData(),

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