Merge pull request #15894 from JoVictorNunes/shared-notes-on-media

This commit is contained in:
Gustavo Trott 2022-11-10 11:44:28 -03:00 committed by GitHub
commit 50010ea528
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 645 additions and 124 deletions

View File

@ -0,0 +1,38 @@
package org.bigbluebutton.core.apps.pads
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core.bus.MessageBus
import org.bigbluebutton.core.models.Pads
import org.bigbluebutton.core.running.LiveMeeting
trait PadPinnedReqMsgHdlr extends RightsManagementTrait {
this: PadsApp2x =>
def handle(msg: PadPinnedReqMsg, liveMeeting: LiveMeeting, bus: MessageBus): Unit = {
def broadcastEvent(groupId: String, pinned: Boolean): Unit = {
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
val envelope = BbbCoreEnvelope(PadPinnedEvtMsg.NAME, routing)
val header = BbbClientMsgHeader(PadPinnedEvtMsg.NAME, liveMeeting.props.meetingProp.intId, "not-used")
val body = PadPinnedEvtMsgBody(groupId, pinned)
val event = PadPinnedEvtMsg(header, body)
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
bus.outGW.send(msgEvent)
}
if (Pads.hasAccess(liveMeeting, msg.body.externalId, msg.header.userId)) {
if (permissionFailed(PermissionCheck.GUEST_LEVEL, PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId)) {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "You need to be the presenter to pin Shared Notes"
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
} else {
Pads.getGroup(liveMeeting.pads, msg.body.externalId) match {
case Some(group) => broadcastEvent(group.externalId, msg.body.pinned)
case _ =>
}
}
}
}
}

View File

@ -13,6 +13,7 @@ class PadsApp2x(implicit val context: ActorContext)
with PadUpdatedSysMsgHdlr with PadUpdatedSysMsgHdlr
with PadContentSysMsgHdlr with PadContentSysMsgHdlr
with PadPatchSysMsgHdlr with PadPatchSysMsgHdlr
with PadUpdatePubMsgHdlr { with PadUpdatePubMsgHdlr
with PadPinnedReqMsgHdlr {
} }

View File

@ -175,6 +175,8 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[PadUpdatePubMsg](envelope, jsonNode) routeGenericMsg[PadUpdatePubMsg](envelope, jsonNode)
case PadCapturePubMsg.NAME => case PadCapturePubMsg.NAME =>
routePadMsg[PadCapturePubMsg](envelope, jsonNode) routePadMsg[PadCapturePubMsg](envelope, jsonNode)
case PadPinnedReqMsg.NAME =>
routeGenericMsg[PadPinnedReqMsg](envelope, jsonNode)
// Voice // Voice
case RecordingStartedVoiceConfEvtMsg.NAME => case RecordingStartedVoiceConfEvtMsg.NAME =>

View File

@ -492,6 +492,7 @@ class MeetingActor(
case m: PadContentSysMsg => padsApp2x.handle(m, liveMeeting, msgBus) case m: PadContentSysMsg => padsApp2x.handle(m, liveMeeting, msgBus)
case m: PadPatchSysMsg => padsApp2x.handle(m, liveMeeting, msgBus) case m: PadPatchSysMsg => padsApp2x.handle(m, liveMeeting, msgBus)
case m: PadUpdatePubMsg => padsApp2x.handle(m, liveMeeting, msgBus) case m: PadUpdatePubMsg => padsApp2x.handle(m, liveMeeting, msgBus)
case m: PadPinnedReqMsg => padsApp2x.handle(m, liveMeeting, msgBus)
// Lock Settings // Lock Settings
case m: ChangeLockSettingsInMeetingCmdMsg => case m: ChangeLockSettingsInMeetingCmdMsg =>

View File

@ -118,3 +118,13 @@ case class PadUpdateCmdMsgBody(groupId: String, name: String, text: String)
object PadCapturePubMsg { val NAME = "PadCapturePubMsg" } object PadCapturePubMsg { val NAME = "PadCapturePubMsg" }
case class PadCapturePubMsg(header: BbbCoreHeaderWithMeetingId, body: PadCapturePubMsgBody) extends PadStandardMsg case class PadCapturePubMsg(header: BbbCoreHeaderWithMeetingId, body: PadCapturePubMsgBody) extends PadStandardMsg
case class PadCapturePubMsgBody(parentMeetingId: String, breakoutId: String, padId: String, meetingName: String, sequence: Int) case class PadCapturePubMsgBody(parentMeetingId: String, breakoutId: String, padId: String, meetingName: String, sequence: Int)
// client -> apps
object PadPinnedReqMsg { val NAME = "PadPinnedReqMsg" }
case class PadPinnedReqMsg(header: BbbClientMsgHeader, body: PadPinnedReqMsgBody) extends StandardMsg
case class PadPinnedReqMsgBody(externalId: String, pinned: Boolean)
// apps -> client
object PadPinnedEvtMsg { val NAME = "PadPinnedEvtMsg" }
case class PadPinnedEvtMsg(header: BbbClientMsgHeader, body: PadPinnedEvtMsgBody) extends BbbCoreMsg
case class PadPinnedEvtMsgBody(externalId: String, pinned: Boolean)

View File

@ -7,6 +7,7 @@ import padContent from './handlers/padContent';
import padTail from './handlers/padTail'; import padTail from './handlers/padTail';
import sessionDeleted from './handlers/sessionDeleted'; import sessionDeleted from './handlers/sessionDeleted';
import captureSharedNotes from './handlers/captureSharedNotes'; import captureSharedNotes from './handlers/captureSharedNotes';
import padPinned from './handlers/padPinned';
RedisPubSub.on('PadGroupCreatedRespMsg', groupCreated); RedisPubSub.on('PadGroupCreatedRespMsg', groupCreated);
RedisPubSub.on('PadCreatedRespMsg', padCreated); RedisPubSub.on('PadCreatedRespMsg', padCreated);
@ -16,3 +17,4 @@ RedisPubSub.on('PadContentEvtMsg', padContent);
RedisPubSub.on('PadTailEvtMsg', padTail); RedisPubSub.on('PadTailEvtMsg', padTail);
RedisPubSub.on('PadSessionDeletedEvtMsg', sessionDeleted); RedisPubSub.on('PadSessionDeletedEvtMsg', sessionDeleted);
RedisPubSub.on('CaptureSharedNotesReqEvtMsg', captureSharedNotes); RedisPubSub.on('CaptureSharedNotesReqEvtMsg', captureSharedNotes);
RedisPubSub.on('PadPinnedEvtMsg', padPinned);

View File

@ -0,0 +1,14 @@
import pinPad from '/imports/api/pads/server/modifiers/pinPad';
export default function padPinned({ header, body }) {
const {
meetingId,
} = header;
const {
externalId,
pinned,
} = body;
pinPad(meetingId, externalId, pinned);
}

View File

@ -2,9 +2,11 @@ import { Meteor } from 'meteor/meteor';
import createGroup from './methods/createGroup'; import createGroup from './methods/createGroup';
import createSession from './methods/createSession'; import createSession from './methods/createSession';
import getPadId from './methods/getPadId'; import getPadId from './methods/getPadId';
import pinPad from './methods/pinPad';
Meteor.methods({ Meteor.methods({
createGroup, createGroup,
createSession, createSession,
getPadId, getPadId,
pinPad,
}); });

View File

@ -0,0 +1,29 @@
import RedisPubSub from '/imports/startup/server/redis';
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { extractCredentials } from '/imports/api/common/server/helpers';
import Logger from '/imports/startup/server/logger';
export default function pinPad(externalId, pinned) {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'PadPinnedReqMsg';
try {
const { meetingId, requesterUserId } = extractCredentials(this.userId);
check(meetingId, String);
check(requesterUserId, String);
check(externalId, String);
check(pinned, Boolean);
const payload = {
externalId,
pinned,
};
RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
} catch (err) {
Logger.error(`Exception while invoking method pinPad ${err.stack}`);
}
}

View File

@ -16,6 +16,7 @@ export default function createPad(meetingId, externalId, padId) {
const modifier = { const modifier = {
$set: { $set: {
padId, padId,
pinned: false,
}, },
}; };

View File

@ -0,0 +1,35 @@
import { check } from 'meteor/check';
import { default as Pads } from '/imports/api/pads';
import Logger from '/imports/startup/server/logger';
export default function pinPad(meetingId, externalId, pinned) {
try {
check(meetingId, String);
check(externalId, String);
check(pinned, Boolean);
if (pinned) {
Pads.update({ meetingId, pinned: true }, { $set: { pinned: false } });
}
const selector = {
meetingId,
externalId,
};
const modifier = {
$set: {
pinned,
},
};
const numberAffected = Pads.update(selector, modifier);
if (numberAffected) {
const prefix = pinned ? '' : 'un';
Logger.debug(`Pad ${prefix}pinned external=${externalId} meeting=${meetingId}`);
}
} catch (err) {
Logger.error(`Pinning pad: ${err}`);
}
}

View File

@ -47,6 +47,7 @@ import Notifications from '../notifications/container';
import GlobalStyles from '/imports/ui/stylesheets/styled-components/globalStyles'; import GlobalStyles from '/imports/ui/stylesheets/styled-components/globalStyles';
import ActionsBarContainer from '../actions-bar/container'; import ActionsBarContainer from '../actions-bar/container';
import PushLayoutEngine from '../layout/push-layout/pushLayoutEngine'; import PushLayoutEngine from '../layout/push-layout/pushLayoutEngine';
import NotesContainer from '/imports/ui/components/notes/container';
const MOBILE_MEDIA = 'only screen and (max-width: 40em)'; const MOBILE_MEDIA = 'only screen and (max-width: 40em)';
const APP_CONFIG = Meteor.settings.public.app; const APP_CONFIG = Meteor.settings.public.app;
@ -479,6 +480,7 @@ class App extends Component {
shouldShowPresentation, shouldShowPresentation,
shouldShowScreenshare, shouldShowScreenshare,
shouldShowExternalVideo, shouldShowExternalVideo,
shouldShowSharedNotes,
isPresenter, isPresenter,
selectedLayout, selectedLayout,
presentationIsOpen, presentationIsOpen,
@ -513,6 +515,7 @@ class App extends Component {
? <ExternalVideoContainer isLayoutSwapped={!presentationIsOpen} isPresenter={isPresenter} /> ? <ExternalVideoContainer isLayoutSwapped={!presentationIsOpen} isPresenter={isPresenter} />
: null : null
} }
{shouldShowSharedNotes ? <NotesContainer area="media" layoutType={selectedLayout} /> : null}
{this.renderCaptions()} {this.renderCaptions()}
<AudioCaptionsSpeechContainer /> <AudioCaptionsSpeechContainer />
{this.renderAudioCaptions()} {this.renderAudioCaptions()}

View File

@ -254,6 +254,7 @@ export default injectIntl(withModalMounter(withTracker(({ intl, baseControls })
const AppSettings = Settings.application; const AppSettings = Settings.application;
const { selectedLayout, pushLayout } = AppSettings; const { selectedLayout, pushLayout } = AppSettings;
const { viewScreenshare } = Settings.dataSaving; const { viewScreenshare } = Settings.dataSaving;
const shouldShowSharedNotes = MediaService.shouldShowSharedNotes();
const shouldShowExternalVideo = MediaService.shouldShowExternalVideo(); const shouldShowExternalVideo = MediaService.shouldShowExternalVideo();
const shouldShowScreenshare = MediaService.shouldShowScreenshare() const shouldShowScreenshare = MediaService.shouldShowScreenshare()
&& (viewScreenshare || currentUser?.presenter); && (viewScreenshare || currentUser?.presenter);
@ -298,8 +299,9 @@ export default injectIntl(withModalMounter(withTracker(({ intl, baseControls })
pushAlertEnabled: AppSettings.chatPushAlerts, pushAlertEnabled: AppSettings.chatPushAlerts,
darkTheme: AppSettings.darkTheme, darkTheme: AppSettings.darkTheme,
shouldShowScreenshare, shouldShowScreenshare,
shouldShowPresentation: !shouldShowScreenshare && !shouldShowExternalVideo, shouldShowPresentation: !shouldShowScreenshare && !shouldShowExternalVideo && !shouldShowSharedNotes,
shouldShowExternalVideo, shouldShowExternalVideo,
shouldShowSharedNotes,
isLargeFont: Session.get('isLargeFont'), isLargeFont: Session.get('isLargeFont'),
presentationRestoreOnUpdate: getFromUserSettings( presentationRestoreOnUpdate: getFromUserSettings(
'bbb_force_restore_presentation_on_new_events', 'bbb_force_restore_presentation_on_new_events',

View File

@ -9,6 +9,7 @@ const Header = ({
rightButtonProps, rightButtonProps,
customRightButton, customRightButton,
'data-test': dataTest, 'data-test': dataTest,
...rest
}) => { }) => {
const renderCloseButton = () => ( const renderCloseButton = () => (
<Right {...rightButtonProps} /> <Right {...rightButtonProps} />
@ -21,8 +22,8 @@ const Header = ({
); );
return ( return (
<Styled.Header data-test={dataTest ? dataTest : ''}> <Styled.Header data-test={dataTest ? dataTest : ''} {...rest}>
<Left {...leftButtonProps} /> {leftButtonProps ? <Left {...leftButtonProps} /> : <div />}
{customRightButton {customRightButton
? renderCustomRightButton() ? renderCustomRightButton()
: rightButtonProps : rightButtonProps

View File

@ -3,6 +3,7 @@ import Auth from '/imports/ui/services/auth';
import { getStreamer } from '/imports/api/external-videos'; import { getStreamer } from '/imports/api/external-videos';
import { makeCall } from '/imports/ui/services/api'; import { makeCall } from '/imports/ui/services/api';
import NotesService from '/imports/ui/components/notes/service';
import ReactPlayer from 'react-player'; import ReactPlayer from 'react-player';
@ -29,6 +30,10 @@ const startWatching = (url) => {
} else if (Panopto.canPlay(url)) { } else if (Panopto.canPlay(url)) {
externalVideoUrl = Panopto.getSocialUrl(url); externalVideoUrl = Panopto.getSocialUrl(url);
} }
// Close Shared Notes if open.
NotesService.pinSharedNotes(false);
makeCall('startWatchingExternalVideo', externalVideoUrl); makeCall('startWatchingExternalVideo', externalVideoUrl);
}; };

View File

@ -1149,6 +1149,55 @@ const reducer = (state, action) => {
}, },
}; };
} }
// NOTES
case ACTIONS.SET_SHARED_NOTES_OUTPUT: {
const {
width,
height,
top,
left,
right,
} = action.value;
const { sharedNotes } = state.output;
if (sharedNotes.width === width
&& sharedNotes.height === height
&& sharedNotes.top === top
&& sharedNotes.left === left
&& sharedNotes.right === right) {
return state;
}
return {
...state,
output: {
...state.output,
sharedNotes: {
...sharedNotes,
width,
height,
top,
left,
right,
},
},
};
}
case ACTIONS.SET_NOTES_IS_PINNED: {
const { sharedNotes } = state.input;
if (sharedNotes.isPinned === action.value) {
return state;
}
return {
...state,
input: {
...state.input,
sharedNotes: {
...sharedNotes,
isPinned: action.value,
},
},
};
}
default: { default: {
throw new Error('Unexpected action'); throw new Error('Unexpected action');
} }

View File

@ -94,6 +94,9 @@ export const ACTIONS = {
SET_HAS_EXTERNAL_VIDEO: 'setHasExternalVideo', SET_HAS_EXTERNAL_VIDEO: 'setHasExternalVideo',
SET_EXTERNAL_VIDEO_SIZE: 'setExternalVideoSize', SET_EXTERNAL_VIDEO_SIZE: 'setExternalVideoSize',
SET_EXTERNAL_VIDEO_OUTPUT: 'setExternalVideoOutput', SET_EXTERNAL_VIDEO_OUTPUT: 'setExternalVideoOutput',
SET_SHARED_NOTES_OUTPUT: 'setSharedNotesOutput',
SET_NOTES_IS_PINNED: 'setNotesIsPinned',
}; };
export const PANELS = { export const PANELS = {

View File

@ -95,6 +95,13 @@ export const INITIAL_INPUT_STATE = {
browserWidth: 0, browserWidth: 0,
browserHeight: 0, browserHeight: 0,
}, },
sharedNotes: {
isPinned: false,
width: 0,
height: 0,
browserWidth: 0,
browserHeight: 0,
},
}; };
export const INITIAL_OUTPUT_STATE = { export const INITIAL_OUTPUT_STATE = {
@ -227,4 +234,13 @@ export const INITIAL_OUTPUT_STATE = {
tabOrder: 0, tabOrder: 0,
zIndex: 1, zIndex: 1,
}, },
sharedNotes: {
display: false,
width: 0,
height: 0,
top: 0,
left: 0,
tabOrder: 0,
zIndex: 1,
},
}; };

View File

@ -683,6 +683,17 @@ const CustomLayout = (props) => {
right: isRTL ? (mediaBounds.right + horizontalCameraDiff) : null, right: isRTL ? (mediaBounds.right + horizontalCameraDiff) : null,
}, },
}); });
layoutContextDispatch({
type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
value: {
width: mediaBounds.width,
height: mediaBounds.height,
top: mediaBounds.top,
left: mediaBounds.left,
right: isRTL ? (mediaBounds.right + horizontalCameraDiff) : null,
},
});
}; };
return null; return null;

View File

@ -470,6 +470,17 @@ const PresentationFocusLayout = (props) => {
right: mediaBounds.right, right: mediaBounds.right,
}, },
}); });
layoutContextDispatch({
type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
value: {
width: isOpen ? mediaBounds.width : 0,
height: isOpen ? mediaBounds.height : 0,
top: mediaBounds.top,
left: mediaBounds.left,
right: mediaBounds.right,
},
});
}; };
return null; return null;

View File

@ -536,6 +536,17 @@ const SmartLayout = (props) => {
right: isRTL ? (mediaBounds.right + horizontalCameraDiff) : null, right: isRTL ? (mediaBounds.right + horizontalCameraDiff) : null,
}, },
}); });
layoutContextDispatch({
type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
value: {
width: mediaBounds.width,
height: mediaBounds.height,
top: mediaBounds.top,
left: mediaBounds.left,
right: isRTL ? (mediaBounds.right + horizontalCameraDiff) : null,
},
});
}; };
return null; return null;

View File

@ -491,6 +491,17 @@ const VideoFocusLayout = (props) => {
right: isRTL ? mediaBounds.right : null, right: isRTL ? mediaBounds.right : null,
}, },
}); });
layoutContextDispatch({
type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
value: {
width: mediaBounds.width,
height: mediaBounds.height,
top: mediaBounds.top,
left: mediaBounds.left,
right: isRTL ? mediaBounds.right : null,
},
});
}; };
return null; return null;

View File

@ -6,6 +6,7 @@ import getFromUserSettings from '/imports/ui/services/users-settings';
import { isExternalVideoEnabled, isScreenSharingEnabled } from '/imports/ui/services/features'; import { isExternalVideoEnabled, isScreenSharingEnabled } from '/imports/ui/services/features';
import { ACTIONS } from '../layout/enums'; import { ACTIONS } from '../layout/enums';
import UserService from '/imports/ui/components/user-list/service'; import UserService from '/imports/ui/components/user-list/service';
import NotesService from '/imports/ui/components/notes/service';
const LAYOUT_CONFIG = Meteor.settings.public.layout; const LAYOUT_CONFIG = Meteor.settings.public.layout;
const KURENTO_CONFIG = Meteor.settings.public.kurento; const KURENTO_CONFIG = Meteor.settings.public.kurento;
@ -34,6 +35,10 @@ function shouldShowExternalVideo() {
return isExternalVideoEnabled() && getVideoUrl(); return isExternalVideoEnabled() && getVideoUrl();
} }
function shouldShowSharedNotes() {
return NotesService.isSharedNotesPinned();
}
function shouldShowOverlay() { function shouldShowOverlay() {
return getFromUserSettings('bbb_enable_video', KURENTO_CONFIG.enableVideo); return getFromUserSettings('bbb_enable_video', KURENTO_CONFIG.enableVideo);
} }
@ -53,4 +58,5 @@ export default {
shouldShowOverlay, shouldShowOverlay,
isVideoBroadcasting, isVideoBroadcasting,
setPresentationIsOpen, setPresentationIsOpen,
shouldShowSharedNotes,
}; };

View File

@ -42,11 +42,13 @@ const NavBarContainer = ({ children, ...props }) => {
const sidebarNavigation = layoutSelectInput((i) => i.sidebarNavigation); const sidebarNavigation = layoutSelectInput((i) => i.sidebarNavigation);
const navBar = layoutSelectOutput((i) => i.navBar); const navBar = layoutSelectOutput((i) => i.navBar);
const layoutContextDispatch = layoutDispatch(); const layoutContextDispatch = layoutDispatch();
const sharedNotes = layoutSelectInput((i) => i.sharedNotes);
const { isPinned: notesIsPinned } = sharedNotes;
const { sidebarContentPanel } = sidebarContent; const { sidebarContentPanel } = sidebarContent;
const { sidebarNavPanel } = sidebarNavigation; const { sidebarNavPanel } = sidebarNavigation;
const hasUnreadNotes = sidebarContentPanel !== PANELS.SHARED_NOTES && unread; const hasUnreadNotes = sidebarContentPanel !== PANELS.SHARED_NOTES && unread && !notesIsPinned;
const hasUnreadMessages = checkUnreadMessages( const hasUnreadMessages = checkUnreadMessages(
{ groupChatsMessages, groupChats, users: users[Auth.meetingID] }, { groupChatsMessages, groupChats, users: users[Auth.meetingID] },
); );

View File

@ -4,11 +4,14 @@ import { defineMessages, injectIntl } from 'react-intl';
import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component'; import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
import Service from '/imports/ui/components/notes/service'; import Service from '/imports/ui/components/notes/service';
import PadContainer from '/imports/ui/components/pads/container'; import PadContainer from '/imports/ui/components/pads/container';
import ConverterButtonContainer from './converter-button/container';
import Styled from './styles'; import Styled from './styles';
import { PANELS, ACTIONS } from '../layout/enums'; import { PANELS, ACTIONS, LAYOUT_TYPE } from '../layout/enums';
import browserInfo from '/imports/utils/browserInfo'; import browserInfo from '/imports/utils/browserInfo';
import Header from '/imports/ui/components/common/control-header/component'; import Header from '/imports/ui/components/common/control-header/component';
import NotesDropdown from '/imports/ui/components/notes/notes-dropdown/container';
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
const intlMessages = defineMessages({ const intlMessages = defineMessages({
hide: { hide: {
@ -19,6 +22,10 @@ const intlMessages = defineMessages({
id: 'app.notes.title', id: 'app.notes.title',
description: 'Title for the shared notes', description: 'Title for the shared notes',
}, },
unpinNotes: {
id: 'app.notes.notesDropdown.unpinNotes',
description: 'Label for unpin shared notes button',
},
}); });
const propTypes = { const propTypes = {
@ -29,6 +36,15 @@ const propTypes = {
hasPermission: PropTypes.bool.isRequired, hasPermission: PropTypes.bool.isRequired,
isResizing: PropTypes.bool.isRequired, isResizing: PropTypes.bool.isRequired,
layoutContextDispatch: PropTypes.func.isRequired, layoutContextDispatch: PropTypes.func.isRequired,
sidebarContent: PropTypes.object.isRequired,
sharedNotesOutput: PropTypes.object.isRequired,
area: PropTypes.string,
layoutType: PropTypes.string,
};
const defaultProps = {
area: 'sidebarContent',
layoutType: null,
}; };
const Notes = ({ const Notes = ({
@ -37,32 +53,105 @@ const Notes = ({
isRTL, isRTL,
layoutContextDispatch, layoutContextDispatch,
isResizing, isResizing,
area,
layoutType,
sidebarContent,
sharedNotesOutput,
amIPresenter,
}) => { }) => {
useEffect(() => () => Service.setLastRev(), []); useEffect(() => () => Service.setLastRev(), []);
const { isChrome } = browserInfo; const { isChrome } = browserInfo;
const isOnMediaArea = area === 'media';
const style = isOnMediaArea ? {
position: 'absolute',
...sharedNotesOutput,
} : {};
const isHidden = isOnMediaArea && (style.width === 0 || style.height === 0);
if (isHidden) style.padding = 0;
useEffect(() => {
if (
isOnMediaArea
&& sidebarContent.isOpen
&& sidebarContent.sidebarContentPanel === PANELS.SHARED_NOTES
) {
if (layoutType === LAYOUT_TYPE.VIDEO_FOCUS) {
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
value: PANELS.CHAT,
});
layoutContextDispatch({
type: ACTIONS.SET_ID_CHAT_OPEN,
value: PUBLIC_CHAT_ID,
});
} else {
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
value: false,
});
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
value: PANELS.NONE,
});
}
layoutContextDispatch({
type: ACTIONS.SET_NOTES_IS_PINNED,
value: true,
});
return () => {
layoutContextDispatch({
type: ACTIONS.SET_NOTES_IS_PINNED,
value: false,
});
};
}
}, []);
const renderHeaderOnMedia = () => {
return amIPresenter ? (
<Styled.Header
rightButtonProps={{
'aria-label': intl.formatMessage(intlMessages.unpinNotes),
'data-test': 'unpinNotes',
icon: 'close',
label: intl.formatMessage(intlMessages.unpinNotes),
onClick: () => {
Service.pinSharedNotes(false);
},
}}
/>
) : null;
};
return ( return (
<Styled.Notes data-test="notes" isChrome={isChrome}> <Styled.Notes data-test="notes" isChrome={isChrome} style={style}>
<Header {!isOnMediaArea ? (
leftButtonProps={{ <Header
onClick: () => { leftButtonProps={{
layoutContextDispatch({ onClick: () => {
type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, layoutContextDispatch({
value: false, type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
}); value: false,
layoutContextDispatch({ });
type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, layoutContextDispatch({
value: PANELS.NONE, type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
}); value: PANELS.NONE,
}, });
'data-test': 'hideNotesLabel', },
'aria-label': intl.formatMessage(intlMessages.hide), 'data-test': 'hideNotesLabel',
label: intl.formatMessage(intlMessages.title), 'aria-label': intl.formatMessage(intlMessages.hide),
}} label: intl.formatMessage(intlMessages.title),
customRightButton={ }}
<ConverterButtonContainer /> customRightButton={
} <NotesDropdown />
/> }
/>
) : renderHeaderOnMedia()}
<PadContainer <PadContainer
externalId={Service.ID} externalId={Service.ID}
hasPermission={hasPermission} hasPermission={hasPermission}
@ -74,5 +163,6 @@ const Notes = ({
}; };
Notes.propTypes = propTypes; Notes.propTypes = propTypes;
Notes.defaultProps = defaultProps;
export default injectWbResizeEvent(injectIntl(Notes)); export default injectWbResizeEvent(injectIntl(Notes));

View File

@ -1,15 +1,29 @@
import React from 'react'; import React, { useContext } from 'react';
import { withTracker } from 'meteor/react-meteor-data'; import { withTracker } from 'meteor/react-meteor-data';
import Notes from './component'; import Notes from './component';
import Service from './service'; import Service from './service';
import { layoutSelectInput, layoutDispatch } from '../layout/context'; import Auth from '/imports/ui/services/auth';
import { layoutSelectInput, layoutDispatch, layoutSelectOutput } from '../layout/context';
import { UsersContext } from '../components-data/users-context/context';
const Container = ({ ...props }) => { const Container = ({ ...props }) => {
const cameraDock = layoutSelectInput((i) => i.cameraDock); const cameraDock = layoutSelectInput((i) => i.cameraDock);
const sharedNotesOutput = layoutSelectOutput((i) => i.sharedNotes);
const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
const { isResizing } = cameraDock; const { isResizing } = cameraDock;
const layoutContextDispatch = layoutDispatch(); const layoutContextDispatch = layoutDispatch();
const usingUsersContext = useContext(UsersContext);
const { users } = usingUsersContext;
const amIPresenter = users[Auth.meetingID][Auth.userID].presenter;
return <Notes {...{ layoutContextDispatch, isResizing, ...props }} />; return <Notes {...{
layoutContextDispatch,
isResizing,
sidebarContent,
sharedNotesOutput,
amIPresenter,
...props
}} />;
}; };
export default withTracker(() => { export default withTracker(() => {

View File

@ -1,45 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import Service from './service';
import Styled from './styles';
import { useState } from 'react';
const DEBOUNCE_TIMEOUT = 15000;
const intlMessages = defineMessages({
convertAndUploadLabel: {
id: 'app.note.converter-button.convertAndUpload',
description: 'Export shared notes as a PDF and upload to the main room',
},
});
const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
amIPresenter: PropTypes.bool.isRequired,
};
const ConverterButtonComponent = ({
intl,
amIPresenter,
}) => {
[converterButtonDisabled, setConverterButtonDisabled] = useState(false);
return (amIPresenter
? (
<Styled.ConvertAndUpload
disabled={converterButtonDisabled}
onClick={() => {
setConverterButtonDisabled(true);
setTimeout(() => setConverterButtonDisabled(false), DEBOUNCE_TIMEOUT);
return Service.convertAndUpload()}}
label={intl.formatMessage(intlMessages.convertAndUploadLabel)}
icon="upload"
/>
)
: null)};
ConverterButtonComponent.propTypes = propTypes;
export default injectIntl(ConverterButtonComponent);

View File

@ -1,31 +0,0 @@
import styled from 'styled-components';
import {
colorWhite,
colorGrayDark,
} from '/imports/ui/stylesheets/styled-components/palette';
import Button from '/imports/ui/components/common/button/component';
const ConvertAndUpload = styled(Button)`
position: relative;
background-color: ${colorWhite};
display: block;
padding: 0;
& > i {
color: ${colorGrayDark};
font-size: smaller;
[dir="rtl"] & {
-webkit-transform: scale(-1, 1);
-moz-transform: scale(-1, 1);
-ms-transform: scale(-1, 1);
-o-transform: scale(-1, 1);
transform: scale(-1, 1);
}
}
&:hover {
background-color: ${colorWhite};
}
`;
export default {
ConvertAndUpload,
};

View File

@ -0,0 +1,137 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import _ from 'lodash';
import BBBMenu from '/imports/ui/components/common/menu/component';
import Trigger from '/imports/ui/components/common/control-header/right/component';
import Service from './service';
const DEBOUNCE_TIMEOUT = 15000;
const NOTES_CONFIG = Meteor.settings.public.notes;
const NOTES_IS_PINNABLE = NOTES_CONFIG.pinnable;
const intlMessages = defineMessages({
convertAndUploadLabel: {
id: 'app.notes.notesDropdown.covertAndUpload',
description: 'Export shared notes as a PDF and upload to the main room',
},
pinNotes: {
id: 'app.notes.notesDropdown.pinNotes',
description: 'Label for pin shared notes button',
},
options: {
id: 'app.notes.notesDropdown.notesOptions',
description: 'Label for shared notes options',
},
});
const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
amIPresenter: PropTypes.bool.isRequired,
isRTL: PropTypes.bool.isRequired,
};
class NotesDropdown extends PureComponent {
constructor(props) {
super(props);
this.state = {
converterButtonDisabled: false,
};
}
setConverterButtonDisabled(value) {
return this.setState({ converterButtonDisabled: value });
}
getAvailableActions() {
const {
intl,
amIPresenter,
} = this.props;
const { converterButtonDisabled } = this.state;
const uploadIcon = 'upload';
const pinIcon = 'presentation';
this.menuItems = [];
if (amIPresenter) {
this.menuItems.push(
{
key: _.uniqueId('notes-option-'),
icon: uploadIcon,
dataTest: 'moveNotesToWhiteboard',
label: intl.formatMessage(intlMessages.convertAndUploadLabel),
disabled: converterButtonDisabled,
onClick: () => {
this.setConverterButtonDisabled(true);
setTimeout(() => this.setConverterButtonDisabled(false), DEBOUNCE_TIMEOUT);
return Service.convertAndUpload();
},
},
);
}
if (amIPresenter && NOTES_IS_PINNABLE) {
this.menuItems.push(
{
key: _.uniqueId('notes-option-'),
icon: pinIcon,
dataTest: 'pinNotes',
label: intl.formatMessage(intlMessages.pinNotes),
onClick: () => {
Service.pinSharedNotes();
},
},
);
}
return this.menuItems;
}
render() {
const {
intl,
isRTL,
} = this.props;
const actions = this.getAvailableActions();
if (actions.length === 0) return null;
return (
<>
<BBBMenu
trigger={
<Trigger
data-test="notesOptionsMenu"
icon="more"
label={intl.formatMessage(intlMessages.options)}
aria-label={intl.formatMessage(intlMessages.options)}
onClick={() => null}
/>
}
opts={{
id: 'notes-options-dropdown',
keepMounted: true,
transitionDuration: 0,
elevation: 3,
getContentAnchorEl: null,
fullwidth: 'true',
anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
}}
actions={actions}
/>
</>
);
}
}
NotesDropdown.propTypes = propTypes;
export default injectIntl(NotesDropdown);

View File

@ -1,14 +1,16 @@
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import ConverterButton from './component'; import NotesDropdown from './component';
import { UsersContext } from '/imports/ui/components/components-data/users-context/context'; import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
import Auth from '/imports/ui/services/auth'; import Auth from '/imports/ui/services/auth';
import { layoutSelect } from '/imports/ui/components/layout/context';
const ConverterButtonContainer = ({ ...props }) => { const NotesDropdownContainer = ({ ...props }) => {
const usingUsersContext = useContext(UsersContext); const usingUsersContext = useContext(UsersContext);
const { users } = usingUsersContext; const { users } = usingUsersContext;
const amIPresenter = users[Auth.meetingID][Auth.userID].presenter; const amIPresenter = users[Auth.meetingID][Auth.userID].presenter;
const isRTL = layoutSelect((i) => i.isRTL);
return <ConverterButton {...{ amIPresenter, ...props }} />; return <NotesDropdown {...{ amIPresenter, isRTL, ...props }} />;
}; };
export default ConverterButtonContainer; export default NotesDropdownContainer;

View File

@ -6,7 +6,6 @@ import { UploadingPresentations } from '/imports/api/presentations';
import _ from 'lodash'; import _ from 'lodash';
const PADS_CONFIG = Meteor.settings.public.pads; const PADS_CONFIG = Meteor.settings.public.pads;
const PRESENTATION_CONFIG = Meteor.settings.public.presentation;
async function convertAndUpload() { async function convertAndUpload() {
@ -30,8 +29,8 @@ async function convertAndUpload() {
done: false, done: false,
error: false error: false
}, },
uploadTimestamp: new Date() uploadTimestamp: new Date(),
}) });
const exportUrl = Auth.authenticateURL(`${PADS_CONFIG.url}/p/${padId}/export/${extension}?${params}`); const exportUrl = Auth.authenticateURL(`${PADS_CONFIG.url}/p/${padId}/export/${extension}?${params}`);
const sharedNotesAsFile = await fetch(exportUrl, { credentials: 'include' }); const sharedNotesAsFile = await fetch(exportUrl, { credentials: 'include' });
@ -51,13 +50,14 @@ async function convertAndUpload() {
conversion: { done: false, error: false }, conversion: { done: false, error: false },
upload: { done: false, error: false, progress: 0 }, upload: { done: false, error: false, progress: 0 },
exportation: { isRunning: false, error: false }, exportation: { isRunning: false, error: false },
onConversion: () => {}, onConversion: () => { },
onUpload: () => {}, onUpload: () => { },
onProgress: () => {}, onProgress: () => { },
onDone: () => {}, onDone: () => { },
}) });
} }
export default { export default {
convertAndUpload, convertAndUpload,
pinSharedNotes: () => NotesService.pinSharedNotes(true),
}; };

View File

@ -73,6 +73,15 @@ const toggleNotesPanel = (sidebarContentPanel, layoutContextDispatch) => {
}); });
}; };
const pinSharedNotes = (pinned) => {
PadsService.pinPad(NOTES_CONFIG.id, pinned);
};
const isSharedNotesPinned = () => {
const pinnedPad = PadsService.getPinnedPad();
return pinnedPad?.externalId === NOTES_CONFIG.id;
};
export default { export default {
ID: NOTES_CONFIG.id, ID: NOTES_CONFIG.id,
toggleNotesPanel, toggleNotesPanel,
@ -81,4 +90,6 @@ export default {
setLastRev, setLastRev,
getLastRev, getLastRev,
hasUnreadNotes, hasUnreadNotes,
isSharedNotesPinned,
pinSharedNotes,
}; };

View File

@ -4,6 +4,7 @@ import {
} from '/imports/ui/stylesheets/styled-components/general'; } from '/imports/ui/stylesheets/styled-components/general';
import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette'; import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
import CommonHeader from '/imports/ui/components/common/control-header/component';
const Notes = styled.div` const Notes = styled.div`
background-color: ${colorWhite}; background-color: ${colorWhite};
@ -23,4 +24,11 @@ const Notes = styled.div`
} }
`; `;
export default { Notes }; const Header = styled(CommonHeader)`
padding-bottom: .2rem;
`;
export default {
Notes,
Header,
};

View File

@ -3,6 +3,14 @@ import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads';
import { makeCall } from '/imports/ui/services/api'; import { makeCall } from '/imports/ui/services/api';
import Auth from '/imports/ui/services/auth'; import Auth from '/imports/ui/services/auth';
import Settings from '/imports/ui/services/settings'; import Settings from '/imports/ui/services/settings';
import {
getVideoUrl,
stopWatching,
} from '/imports/ui/components/external-video-player/service';
import {
screenshareHasEnded,
isVideoBroadcasting,
} from '/imports/ui/components/screenshare/service';
const PADS_CONFIG = Meteor.settings.public.pads; const PADS_CONFIG = Meteor.settings.public.pads;
const THROTTLE_TIMEOUT = 2000; const THROTTLE_TIMEOUT = 2000;
@ -96,6 +104,36 @@ const getPadContent = (externalId) => {
return ''; return '';
}; };
const getPinnedPad = () => {
const pad = Pads.findOne({
meetingId: Auth.meetingID,
pinned: true,
}, {
fields: {
externalId: 1,
},
});
return pad;
};
const pinPad = (externalId, pinned) => {
if (pinned) {
// Stop external video sharing if it's running.
if (getVideoUrl()) stopWatching();
// Stop screen sharing if it's running.
if (isVideoBroadcasting()) screenshareHasEnded();
}
makeCall('pinPad', externalId, pinned);
};
const throttledPinPad = _.throttle(pinPad, 1000, {
leading: true,
trailing: false,
});
export default { export default {
getPadId, getPadId,
createGroup, createGroup,
@ -106,4 +144,6 @@ export default {
getPadTail, getPadTail,
getPadContent, getPadContent,
getParams, getParams,
getPinnedPad,
pinPad: throttledPinPad,
}; };

View File

@ -11,6 +11,7 @@ import { Meteor } from "meteor/meteor";
import MediaStreamUtils from '/imports/utils/media-stream-utils'; import MediaStreamUtils from '/imports/utils/media-stream-utils';
import ConnectionStatusService from '/imports/ui/components/connection-status/service'; import ConnectionStatusService from '/imports/ui/components/connection-status/service';
import browserInfo from '/imports/utils/browserInfo'; import browserInfo from '/imports/utils/browserInfo';
import NotesService from '/imports/ui/components/notes/service';
const VOLUME_CONTROL_ENABLED = Meteor.settings.public.kurento.screenshare.enableVolumeControl; const VOLUME_CONTROL_ENABLED = Meteor.settings.public.kurento.screenshare.enableVolumeControl;
const SCREENSHARE_MEDIA_ELEMENT_NAME = 'screenshareVideo'; const SCREENSHARE_MEDIA_ELEMENT_NAME = 'screenshareVideo';
@ -176,6 +177,9 @@ const shareScreen = async (isPresenter, onFail) => {
return; return;
} }
// Close Shared Notes if open.
NotesService.pinSharedNotes(false);
setSharingScreen(true); setSharingScreen(true);
} catch (error) { } catch (error) {
onFail(error); onFail(error);

View File

@ -34,6 +34,10 @@ const intlMessages = defineMessages({
id: 'app.userList.byModerator', id: 'app.userList.byModerator',
description: '', description: '',
}, },
disabled: {
id: 'app.notes.disabled',
description: 'Aria description for disabled notes button',
},
}); });
class UserNotes extends Component { class UserNotes extends Component {
@ -57,7 +61,7 @@ class UserNotes extends Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { sidebarContentPanel, rev } = this.props; const { sidebarContentPanel, rev, isPinned } = this.props;
const { unread } = this.state; const { unread } = this.state;
if (sidebarContentPanel !== PANELS.SHARED_NOTES && !unread) { if (sidebarContentPanel !== PANELS.SHARED_NOTES && !unread) {
@ -67,6 +71,8 @@ class UserNotes extends Component {
if (sidebarContentPanel === PANELS.SHARED_NOTES && unread) { if (sidebarContentPanel === PANELS.SHARED_NOTES && unread) {
this.setUnread(false); this.setUnread(false);
} }
if (!isPinned && prevProps.isPinned && unread) this.setUnread(false);
} }
setUnread(unread) { setUnread(unread) {
@ -79,11 +85,12 @@ class UserNotes extends Component {
disableNotes, disableNotes,
sidebarContentPanel, sidebarContentPanel,
layoutContextDispatch, layoutContextDispatch,
isPinned,
} = this.props; } = this.props;
const { unread } = this.state; const { unread } = this.state;
let notification = null; let notification = null;
if (unread) { if (unread && !isPinned) {
notification = ( notification = (
<Styled.UnreadMessages aria-label={intl.formatMessage(intlMessages.unreadContent)}> <Styled.UnreadMessages aria-label={intl.formatMessage(intlMessages.unreadContent)}>
<Styled.UnreadMessagesText aria-hidden="true"> <Styled.UnreadMessagesText aria-hidden="true">
@ -101,6 +108,9 @@ class UserNotes extends Component {
tabIndex={0} tabIndex={0}
onClick={() => NotesService.toggleNotesPanel(sidebarContentPanel, layoutContextDispatch)} onClick={() => NotesService.toggleNotesPanel(sidebarContentPanel, layoutContextDispatch)}
onKeyPress={() => { }} onKeyPress={() => { }}
as={isPinned ? 'button' : 'div'}
disabled={isPinned}
$disabled={isPinned}
> >
<Icon iconName="copy" /> <Icon iconName="copy" />
<div aria-hidden> <div aria-hidden>
@ -114,6 +124,10 @@ class UserNotes extends Component {
<span id="lockedNotes">{`${intl.formatMessage(intlMessages.locked)} ${intl.formatMessage(intlMessages.byModerator)}`}</span> <span id="lockedNotes">{`${intl.formatMessage(intlMessages.locked)} ${intl.formatMessage(intlMessages.byModerator)}`}</span>
</Styled.NotesLock> </Styled.NotesLock>
) : null} ) : null}
{isPinned
? (
<span className='sr-only'>{`${intl.formatMessage(intlMessages.disabled)}`}</span>
) : null}
</div> </div>
{notification} {notification}
</Styled.ListItem> </Styled.ListItem>

View File

@ -18,5 +18,6 @@ export default lockContextContainer(withTracker(({ userLocks }) => {
return { return {
rev: PadsService.getRev(NotesService.ID), rev: PadsService.getRev(NotesService.ID),
disableNotes: shouldDisableNotes, disableNotes: shouldDisableNotes,
isPinned: NotesService.isSharedNotesPinned(),
}; };
})(UserNotesContainer)); })(UserNotesContainer));

View File

@ -13,7 +13,12 @@ const UnreadMessages = styled(StyledContent.UnreadMessages)``;
const UnreadMessagesText = styled(StyledContent.UnreadMessagesText)``; const UnreadMessagesText = styled(StyledContent.UnreadMessagesText)``;
const ListItem = styled(StyledContent.ListItem)``; const ListItem = styled(StyledContent.ListItem)`
${({ $disabled }) => $disabled && `
cursor: not-allowed;
border: none;
`}
`;
const NotesTitle = styled.div` const NotesTitle = styled.div`
font-weight: 400; font-weight: 400;

View File

@ -528,6 +528,7 @@ public:
notes: notes:
enabled: true enabled: true
id: notes id: notes
pinnable: true
layout: layout:
hidePresentation: false hidePresentation: false
showParticipantsOnLogin: true showParticipantsOnLogin: true

View File

@ -84,8 +84,12 @@
"app.notes.title": "Shared Notes", "app.notes.title": "Shared Notes",
"app.notes.label": "Notes", "app.notes.label": "Notes",
"app.notes.hide": "Hide notes", "app.notes.hide": "Hide notes",
"app.note.converter-button.convertAndUpload": "Move notes to whiteboard",
"app.notes.locked": "Locked", "app.notes.locked": "Locked",
"app.notes.disabled": "Pinned on media area",
"app.notes.notesDropdown.covertAndUpload": "Convert notes to presentation",
"app.notes.notesDropdown.pinNotes": "Pin notes onto whiteboard",
"app.notes.notesDropdown.unpinNotes": "Unpin notes",
"app.notes.notesDropdown.notesOptions": "Notes options",
"app.pads.hint": "Press Esc to focus the pad's toolbar", "app.pads.hint": "Press Esc to focus the pad's toolbar",
"app.user.activityCheck": "User activity check", "app.user.activityCheck": "User activity check",
"app.user.activityCheck.label": "Check if user is still in meeting ({0})", "app.user.activityCheck.label": "Check if user is still in meeting ({0})",