Merge pull request #18364 from gustavotrott/merge27-into-develop-21jul2023

Merge 2.7 into Develop
This commit is contained in:
Gustavo Trott 2023-07-21 17:24:02 -03:00 committed by GitHub
commit 1e9f70dd1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 338 additions and 118 deletions

View File

@ -0,0 +1,46 @@
package org.bigbluebutton.core.apps.users
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core.models.Users2x
import org.bigbluebutton.core.running.{ BaseMeetingActor, LiveMeeting, OutMsgRouter }
trait ClearAllUsersReactionCmdMsgHdlr extends RightsManagementTrait {
this: BaseMeetingActor =>
val liveMeeting: LiveMeeting
val outGW: OutMsgRouter
def handleClearAllUsersReactionCmdMsg(msg: ClearAllUsersReactionCmdMsg) {
val isUserModerator = !permissionFailed(
PermissionCheck.MOD_LEVEL,
PermissionCheck.VIEWER_LEVEL,
liveMeeting.users2x,
msg.header.userId
)
if (isUserModerator) {
for {
user <- Users2x.findAll(liveMeeting.users2x)
} yield {
//Don't clear away and RaiseHand
Users2x.setReactionEmoji(liveMeeting.users2x, user.intId, "none")
}
sendClearedAllUsersReactionEvtMsg(outGW, liveMeeting.props.meetingProp.intId, msg.header.userId)
} else {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to clear users reactions."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting)
}
}
def sendClearedAllUsersReactionEvtMsg(outGW: OutMsgRouter, meetingId: String, userId: String): Unit = {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, userId)
val envelope = BbbCoreEnvelope(ClearedAllUsersReactionEvtMsg.NAME, routing)
val header = BbbClientMsgHeader(ClearedAllUsersReactionEvtMsg.NAME, meetingId, userId)
val body = ClearedAllUsersReactionEvtMsgBody()
val event = ClearedAllUsersReactionEvtMsg(header, body)
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
outGW.send(msgEvent)
}
}

View File

@ -9,6 +9,7 @@ trait UsersApp2x
with GetLockSettingsReqMsgHdlr
with ChangeUserEmojiCmdMsgHdlr
with ClearAllUsersEmojiCmdMsgHdlr
with ClearAllUsersReactionCmdMsgHdlr
with UserReactionTimeExpiredCmdMsgHdlr {
this: MeetingActor =>

View File

@ -264,6 +264,8 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[UserReactionTimeExpiredCmdMsg](envelope, jsonNode)
case ClearAllUsersEmojiCmdMsg.NAME =>
routeGenericMsg[ClearAllUsersEmojiCmdMsg](envelope, jsonNode)
case ClearAllUsersReactionCmdMsg.NAME =>
routeGenericMsg[ClearAllUsersReactionCmdMsg](envelope, jsonNode)
case ChangeUserRoleCmdMsg.NAME =>
routeGenericMsg[ChangeUserRoleCmdMsg](envelope, jsonNode)

View File

@ -391,6 +391,7 @@ class MeetingActor(
case m: ChangeUserAwayReqMsg => usersApp.handleChangeUserAwayReqMsg(m)
case m: UserReactionTimeExpiredCmdMsg => handleUserReactionTimeExpiredCmdMsg(m)
case m: ClearAllUsersEmojiCmdMsg => handleClearAllUsersEmojiCmdMsg(m)
case m: ClearAllUsersReactionCmdMsg => handleClearAllUsersReactionCmdMsg(m)
case m: SelectRandomViewerReqMsg => usersApp.handleSelectRandomViewerReqMsg(m)
case m: ChangeUserPinStateReqMsg => usersApp.handleChangeUserPinStateReqMsg(m)
case m: ChangeUserMobileFlagReqMsg => usersApp.handleChangeUserMobileFlagReqMsg(m)

View File

@ -272,6 +272,20 @@ object ClearedAllUsersEmojiEvtMsg { val NAME = "ClearedAllUsersEmojiEvtMsg" }
case class ClearedAllUsersEmojiEvtMsg(header: BbbClientMsgHeader, body: ClearedAllUsersEmojiEvtMsgBody) extends StandardMsg
case class ClearedAllUsersEmojiEvtMsgBody()
/**
* Sent from client about a mod clearing all users' Reaction.
*/
object ClearAllUsersReactionCmdMsg { val NAME = "ClearAllUsersReactionCmdMsg" }
case class ClearAllUsersReactionCmdMsg(header: BbbClientMsgHeader, body: ClearAllUsersReactionCmdMsgBody) extends StandardMsg
case class ClearAllUsersReactionCmdMsgBody(userId: String)
/**
* Sent to all clients about clearing all users' Reaction.
*/
object ClearedAllUsersReactionEvtMsg { val NAME = "ClearedAllUsersReactionEvtMsg" }
case class ClearedAllUsersReactionEvtMsg(header: BbbClientMsgHeader, body: ClearedAllUsersReactionEvtMsgBody) extends StandardMsg
case class ClearedAllUsersReactionEvtMsgBody()
/**
* Sent from client about a user mobile flag.
*/

View File

@ -1,4 +1,6 @@
import RedisPubSub from '/imports/startup/server/redis';
import handleSetUserReaction from './handlers/setUserReaction';
import handleClearUsersReaction from './handlers/clearUsersReaction';
RedisPubSub.on('UserReactionEmojiChangedEvtMsg', handleSetUserReaction);
RedisPubSub.on('ClearedAllUsersReactionEvtMsg', handleClearUsersReaction);

View File

@ -0,0 +1,7 @@
import { check } from 'meteor/check';
import clearReactions from '../modifiers/clearReactions';
export default function handleClearUsersReaction({ body }, meetingId) {
check(meetingId, String);
clearReactions(meetingId);
}

View File

@ -1,6 +1,8 @@
import { Meteor } from 'meteor/meteor';
import setUserReaction from './methods/setUserReaction';
import clearAllUsersReaction from './methods/clearAllUsersReaction';
Meteor.methods({
setUserReaction,
clearAllUsersReaction,
});

View File

@ -0,0 +1,30 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import RedisPubSub from '/imports/startup/server/redis';
import Logger from '/imports/startup/server/logger';
import { extractCredentials } from '/imports/api/common/server/helpers';
export default function clearAllUsersReaction() {
try {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'ClearAllUsersReactionCmdMsg';
const { meetingId, requesterUserId } = extractCredentials(this.userId);
check(meetingId, String);
check(requesterUserId, String);
const payload = {
userId: requesterUserId,
};
Logger.verbose('Sending clear all users reactions', {
requesterUserId, meetingId,
});
RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
} catch (err) {
Logger.error(`Exception while invoking method clearAllUsersReaction ${err.stack}`);
}
}

View File

@ -45,13 +45,13 @@ const intlMessages = defineMessages({
id: 'app.actionsBar.actionsDropdown.actionsLabel',
description: 'Actions button label',
},
activateTimerLabel: {
id: 'app.actionsBar.actionsDropdown.activateTimerLabel',
description: 'Activate timer label',
activateTimerStopwatchLabel: {
id: 'app.actionsBar.actionsDropdown.activateTimerStopwatchLabel',
description: 'Activate timer/stopwatch label',
},
deactivateTimerLabel: {
id: 'app.actionsBar.actionsDropdown.deactivateTimerLabel',
description: 'Deactivate timer label',
deactivateTimerStopwatchLabel: {
id: 'app.actionsBar.actionsDropdown.deactivateTimerStopwatchLabel',
description: 'Deactivate timer/stopwatch label',
},
presentationLabel: {
id: 'app.actionsBar.actionsDropdown.presentationLabel',
@ -263,8 +263,8 @@ class ActionsDropdown extends PureComponent {
actions.push({
icon: 'time',
label: isTimerActive
? intl.formatMessage(intlMessages.deactivateTimerLabel)
: intl.formatMessage(intlMessages.activateTimerLabel),
? intl.formatMessage(intlMessages.deactivateTimerStopwatchLabel)
: intl.formatMessage(intlMessages.activateTimerStopwatchLabel),
key: this.timerId,
onClick: () => this.handleTimerClick(),
});
@ -403,7 +403,7 @@ class ActionsDropdown extends PureComponent {
<BBBMenu
customStyles={!isMobile ? customStyles : null}
accessKey={OPEN_ACTIONS_AK}
trigger={
trigger={(
<Styled.HideDropdownButton
open={isDropdownOpen}
hideLabel
@ -416,7 +416,7 @@ class ActionsDropdown extends PureComponent {
circle
onClick={() => null}
/>
}
)}
actions={children}
opts={{
id: 'actions-dropdown-menu',

View File

@ -6,7 +6,7 @@ import ActionsDropdown from './actions-dropdown/container';
import AudioCaptionsButtonContainer from '/imports/ui/components/audio/captions/button/container';
import CaptionsReaderMenuContainer from '/imports/ui/components/captions/reader-menu/container';
import ScreenshareButtonContainer from '/imports/ui/components/actions-bar/screenshare/container';
import InteractionsButtonContainer from '/imports/ui/components/actions-bar/interactions-button/container';
import ReactionsButtonContainer from './reactions-button/container';
import AudioControlsContainer from '../audio/audio-controls/container';
import JoinVideoOptionsContainer from '../video-provider/video-button/container';
import PresentationOptionsContainer from './presentation-options/component';
@ -32,14 +32,14 @@ class ActionsBar extends PureComponent {
renderRaiseHand() {
const {
isInteractionsButtonEnabled, isRaiseHandButtonEnabled, setEmojiStatus, currentUser, intl,
isReactionsButtonEnabled, isRaiseHandButtonEnabled, setEmojiStatus, currentUser, intl,
} = this.props;
return (<>
{isInteractionsButtonEnabled ?
{isReactionsButtonEnabled ?
<>
<Styled.Separator />
<InteractionsButtonContainer actionsBarRef={this.actionsBarRef} />
<ReactionsButtonContainer actionsBarRef={this.actionsBarRef} />
</> :
isRaiseHandButtonEnabled ? <RaiseHandDropdownContainer {...{ setEmojiStatus, currentUser, intl }} />
: null}

View File

@ -50,9 +50,9 @@ const SELECT_RANDOM_USER_ENABLED = Meteor.settings.public.selectRandomUser.enabl
const RAISE_HAND_BUTTON_ENABLED = Meteor.settings.public.app.raiseHandActionButton.enabled;
const RAISE_HAND_BUTTON_CENTERED = Meteor.settings.public.app.raiseHandActionButton.centered;
const isInteractionsButtonEnabled = () => {
const INTERACTIONS_BUTTON_ENABLED = Meteor.settings.public.app.interactionsButton.enabled;
return getFromUserSettings('enable-interactions-button', INTERACTIONS_BUTTON_ENABLED);
const isReactionsButtonEnabled = () => {
const REACTIONS_BUTTON_ENABLED = Meteor.settings.public.app.reactionsButton.enabled;
return getFromUserSettings('enable-reactions-button', REACTIONS_BUTTON_ENABLED);
};
export default withTracker(() => ({
@ -75,7 +75,7 @@ export default withTracker(() => ({
isSelectRandomUserEnabled: SELECT_RANDOM_USER_ENABLED,
isRaiseHandButtonEnabled: RAISE_HAND_BUTTON_ENABLED,
isRaiseHandButtonCentered: RAISE_HAND_BUTTON_CENTERED,
isInteractionsButtonEnabled: isInteractionsButtonEnabled(),
isReactionsButtonEnabled: isReactionsButtonEnabled(),
isThereCurrentPresentation: Presentations.findOne({ meetingId: Auth.meetingID, current: true },
{ fields: {} }),
allowExternalVideo: isExternalVideoEnabled(),

View File

@ -8,21 +8,22 @@ import UserListService from '/imports/ui/components/user-list/service';
import Styled from '../styles';
const InteractionsButton = (props) => {
const ReactionsButton = (props) => {
const {
intl,
actionsBarRef,
userId,
raiseHand,
isMobile,
currentUserReaction,
} = props;
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const intlMessages = defineMessages({
interactionsLabel: {
id: 'app.actionsBar.interactions.interactions',
description: 'interactions Label',
reactionsLabel: {
id: 'app.actionsBar.reactions.reactionsButtonLabel',
description: 'reactions Label',
},
});
@ -34,13 +35,12 @@ const InteractionsButton = (props) => {
};
const handleReactionSelect = (reaction) => {
UserReactionService.setUserReaction(reaction);
handleClose();
const newReaction = currentUserReaction === reaction ? 'none' : reaction;
UserReactionService.setUserReaction(newReaction);
};
const handleRaiseHandButtonClick = () => {
UserListService.setUserRaiseHand(userId, !raiseHand);
handleClose();
};
const renderReactionsBar = () => (
@ -54,12 +54,12 @@ const InteractionsButton = (props) => {
return (
<BBBMenu
trigger={(
<Styled.InteractionsDropdown>
<Styled.ReactionsDropdown>
<Styled.RaiseHandButton
data-test="InteractionsButton"
data-test="ReactionsButton"
icon="hand"
label={intl.formatMessage(intlMessages.interactionsLabel)}
description="Interactions"
label={intl.formatMessage(intlMessages.reactionsLabel)}
description="Reactions"
ghost={!showEmojiPicker}
onKeyPress={() => {}}
onClick={() => setShowEmojiPicker(true)}
@ -68,7 +68,7 @@ const InteractionsButton = (props) => {
circle
size="lg"
/>
</Styled.InteractionsDropdown>
</Styled.ReactionsDropdown>
)}
renderOtherComponents={showEmojiPicker ? renderReactionsBar() : null}
onCloseCallback={() => handleClose()}
@ -82,7 +82,7 @@ const InteractionsButton = (props) => {
keepMounted: true,
transitionDuration: 0,
elevation: 3,
getContentAnchorEl: null,
getcontentanchorel: null,
anchorOrigin: { vertical: 'top', horizontal: 'center' },
transformOrigin: { vertical: 'bottom', horizontal: 'center' },
}}
@ -100,6 +100,6 @@ const propTypes = {
layoutContextDispatch: PropTypes.func.isRequired,
};
InteractionsButton.propTypes = propTypes;
ReactionsButton.propTypes = propTypes;
export default InteractionsButton;
export default ReactionsButton;

View File

@ -2,11 +2,12 @@ import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import { layoutSelectInput, layoutDispatch } from '/imports/ui/components/layout/context';
import { injectIntl } from 'react-intl';
import InteractionsButton from './component';
import ReactionsButton from './component';
import actionsBarService from '../service';
import UserReactionService from '/imports/ui/components/user-reaction/service';
import { SMALL_VIEWPORT_BREAKPOINT } from '/imports/ui/components/layout/enums';
const InteractionsButtonContainer = ({ ...props }) => {
const ReactionsButtonContainer = ({ ...props }) => {
const layoutContextDispatch = layoutDispatch();
const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
const { sidebarContentPanel } = sidebarContent;
@ -15,7 +16,7 @@ const InteractionsButtonContainer = ({ ...props }) => {
const isMobile = browserWidth <= SMALL_VIEWPORT_BREAKPOINT;
return (
<InteractionsButton {...{
<ReactionsButton {...{
layoutContextDispatch, sidebarContentPanel, isMobile, ...props,
}}
/>
@ -24,11 +25,13 @@ const InteractionsButtonContainer = ({ ...props }) => {
export default injectIntl(withTracker(() => {
const currentUser = actionsBarService.currentUser();
const currentUserReaction = UserReactionService.getUserReaction(currentUser.userId);
return {
userId: currentUser.userId,
emoji: currentUser.emoji,
currentUserReaction: currentUserReaction.reaction,
raiseHand: currentUser.raiseHand,
};
})(InteractionsButtonContainer));
})(ReactionsButtonContainer));

View File

@ -96,7 +96,7 @@ const ButtonContainer = styled.div`
}
`;
const InteractionsDropdown = styled.div`
const ReactionsDropdown = styled.div`
position: relative;
`;
@ -141,7 +141,7 @@ export default {
Right,
RaiseHandButton,
ButtonContainer,
InteractionsDropdown,
ReactionsDropdown,
Wrapper,
Separator,
};

View File

@ -75,6 +75,10 @@ const intlMessages = defineMessages({
id: 'app.toast.clearedEmoji.label',
description: 'message for cleared emoji status',
},
clearedReaction: {
id: 'app.toast.clearedReactions.label',
description: 'message for cleared reactions',
},
setEmoji: {
id: 'app.toast.setEmoji.label',
description: 'message when a user emoji has been set',

View File

@ -92,14 +92,16 @@ const AppContainer = (props) => {
const { focusedId } = cameraDock;
if(
layoutContextDispatch
&& (typeof meetingLayout != "undefined")
&& (layoutType.current != meetingLayout)
useEffect(() => {
if (
layoutContextDispatch
&& (typeof meetingLayout !== 'undefined')
&& (layoutType.current !== meetingLayout)
) {
layoutType.current = meetingLayout;
MediaService.setPresentationIsOpen(layoutContextDispatch, true);
}
}
}, [meetingLayout, layoutContextDispatch, layoutType]);
const horizontalPosition = cameraDock.position === 'contentLeft' || cameraDock.position === 'contentRight';
// this is not exactly right yet
@ -140,7 +142,8 @@ const AppContainer = (props) => {
};
useEffect(() => {
MediaService.buildLayoutWhenPresentationAreaIsDisabled(layoutContextDispatch)});
MediaService.buildLayoutWhenPresentationAreaIsDisabled(layoutContextDispatch)
});
return currentUserId
? (

View File

@ -100,7 +100,7 @@ class BBBMenu extends React.Component {
const { actions, selectedEmoji, intl } = this.props;
return actions?.map(a => {
const { dataTest, label, onClick, key, disabled, accessKey, description, selected } = a;
const { dataTest, label, onClick, key, disabled, description, selected } = a;
const emojiSelected = key?.toLowerCase()?.includes(selectedEmoji?.toLowerCase());
let customStyles = {
@ -143,7 +143,7 @@ class BBBMenu extends React.Component {
</Styled.BBBMenuItem>,
a.divider && <Divider disabled />
];
});
}) ?? [];
}
render() {
@ -247,18 +247,7 @@ BBBMenu.propTypes = {
trigger: PropTypes.element.isRequired,
actions: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
onClick: PropTypes.func,
icon: PropTypes.string,
iconRight: PropTypes.string,
disabled: PropTypes.bool,
divider: PropTypes.bool,
dividerTop: PropTypes.bool,
accessKey: PropTypes.string,
dataTest: PropTypes.string,
})).isRequired,
actions: PropTypes.array.isRequired,
onCloseCallback: PropTypes.func,
dataTest: PropTypes.string,

View File

@ -19,7 +19,7 @@ const BaseModal = (props) => {
}, []);
useEffect(() => {
// Only add event listener if name is specified
if (!modalName) return;
if (!modalName) return () => null;
const closeEventName = `CLOSE_MODAL_${modalName.toUpperCase()}`;
@ -28,7 +28,7 @@ const BaseModal = (props) => {
// Remove listener on unmount
return () => {
document.removeEventListener(closeEventName, closeEventHandler);
document.removeEventListener(closeEventName, closeEventHandler);
};
}, []);
const priorityValue = priority || 'low';

View File

@ -114,7 +114,7 @@ const Adapter = () => {
}, [usingUsersContext]);
useEffect(() => {
if (!Meteor.status().connected) return;
if (!Meteor.status().connected) return () => null;
setSync(false);
dispatch({
type: ACTIONS.CLEAR_ALL,

View File

@ -20,7 +20,6 @@ const Adapter = () => {
},
});
}, []);
return null;
};
export default Adapter;

View File

@ -14,11 +14,11 @@ const propTypes = {
const intlMessages = defineMessages({
raiseHandLabel: {
id: 'app.actionsBar.interactions.raiseHand',
id: 'app.actionsBar.reactions.raiseHand',
description: 'raise Hand Label',
},
notRaiseHandLabel: {
id: 'app.actionsBar.interactions.lowHand',
id: 'app.actionsBar.reactions.lowHand',
description: 'not Raise Hand Label',
},
});
@ -57,6 +57,7 @@ const ReactionsPicker = (props) => {
onRaiseHand,
raiseHand,
isMobile,
currentUserReaction,
} = props;
const RaiseHandButtonLabel = () => {
@ -67,16 +68,21 @@ const ReactionsPicker = (props) => {
: intl.formatMessage(intlMessages.raiseHandLabel);
};
const emojiProps = {
native: true,
size: '1.5rem',
};
return (
<Styled.Wrapper isMobile={isMobile}>
{reactions.map(({ id, native }) => (
<Styled.ButtonWrapper>
<Emoji key={id} emoji={{ id }} size={30} onClick={() => onReactionSelect(native)} />
<Styled.ButtonWrapper active={currentUserReaction === native}>
<Emoji key={id} emoji={{ id }} onClick={() => onReactionSelect(native)} {...emojiProps} />
</Styled.ButtonWrapper>
))}
<Styled.Separator isMobile={isMobile} />
<Styled.RaiseHandButtonWrapper onClick={() => onRaiseHand()} active={raiseHand}>
<Emoji key='hand' emoji={{ id: 'hand' }} size={30} />
<Emoji key='hand' emoji={{ id: 'hand' }} {...emojiProps} />
{RaiseHandButtonLabel()}
</Styled.RaiseHandButtonWrapper>
</Styled.Wrapper>

View File

@ -41,6 +41,18 @@ const ButtonWrapper = styled.div`
height: 1.8rem !important;
width: 1.8rem !important;
}
${({ active }) => active && `
color: ${btnPrimaryColor};
background-color: ${btnPrimaryBg};
border: none;
&:hover{
filter: brightness(90%);
color: ${btnPrimaryColor};
background-color: ${btnPrimaryHoverBg} !important;
}
`}
`;
const RaiseHandButtonWrapper = styled(ButtonWrapper)`

View File

@ -64,7 +64,7 @@ const CustomLayout = (props) => {
}, []);
useEffect(() => {
if (deviceType === null) return;
if (deviceType === null) return () => null;
if (deviceType !== prevDeviceType) {
// reset layout if deviceType changed

View File

@ -64,7 +64,7 @@ const PresentationFocusLayout = (props) => {
}, []);
useEffect(() => {
if (deviceType === null) return;
if (deviceType === null) return () => null;
if (deviceType !== prevDeviceType) {
// reset layout if deviceType changed

View File

@ -59,7 +59,7 @@ const SmartLayout = (props) => {
}, []);
useEffect(() => {
if (deviceType === null) return;
if (deviceType === null) return () => null;
if (deviceType !== prevDeviceType) {
// reset layout if deviceType changed

View File

@ -67,7 +67,7 @@ const VideoFocusLayout = (props) => {
}, []);
useEffect(() => {
if (deviceType === null) return;
if (deviceType === null) return () => null;
if (deviceType !== prevDeviceType) {
// reset layout if deviceType changed

View File

@ -151,7 +151,6 @@ const Notes = ({
value: true,
});
}
return null;
}, []);
const renderHeaderOnMedia = () => {

View File

@ -139,7 +139,7 @@ class PresentationDownloadDropdown extends PureComponent {
keepMounted: true,
transitionDuration: 0,
elevation: 2,
getContentAnchorEl: null,
getcontentanchorel: null,
fullwidth: 'true',
anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },

View File

@ -65,9 +65,6 @@ const SidebarContent = (props) => {
}
}, [width, height]);
useEffect(() => {
}, [resizeStartWidth, resizeStartHeight]);
const setSidebarContentSize = (dWidth, dHeight) => {
const newWidth = resizeStartWidth + dWidth;
const newHeight = resizeStartHeight + dHeight;

View File

@ -46,9 +46,6 @@ const SidebarNavigation = (props) => {
if (!isResizing) setResizableWidth(width);
}, [width]);
useEffect(() => {
}, [resizeStartWidth]);
const setSidebarNavWidth = (dWidth) => {
const newWidth = resizeStartWidth + dWidth;

View File

@ -13,8 +13,6 @@ import SubscriptionRegistry, {
import { isChatEnabled } from '/imports/ui/services/features';
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
const PUBLIC_CHAT_TYPE = CHAT_CONFIG.type_public;
const TYPING_INDICATOR_ENABLED = CHAT_CONFIG.typingIndicator.enabled;
const SUBSCRIPTIONS = [
'users',

View File

@ -12,13 +12,10 @@ const trackName = Meteor.settings.public.timer.music;
const TAB_TIMER_INDICATOR = Meteor.settings.public.timer.tabIndicator;
const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
timer: PropTypes.shape({
stopwatch: PropTypes.bool,
running: PropTypes.bool,
time: PropTypes.string,
time: PropTypes.number,
accumulated: PropTypes.number,
timestamp: PropTypes.number,
}).isRequired,
@ -28,7 +25,7 @@ const propTypes = {
sidebarContentIsOpen: PropTypes.bool.isRequired,
timeOffset: PropTypes.number.isRequired,
isModerator: PropTypes.bool.isRequired,
currentTrack: PropTypes.string.isRequired,
currentTrack: PropTypes.bool.isRequired,
};
class Indicator extends Component {

View File

@ -527,6 +527,10 @@ const clearAllEmojiStatus = () => {
makeCall('clearAllUsersEmoji');
};
const clearAllReactions = () => {
makeCall('clearAllUsersReaction');
};
const assignPresenter = (userId) => { makeCall('assignPresenter', userId); };
const removeUser = (userId, banUser) => {
@ -792,6 +796,7 @@ export default {
setUserAway,
setUserRaiseHand,
clearAllEmojiStatus,
clearAllReactions,
assignPresenter,
removeUser,
toggleVoice,

View File

@ -22,6 +22,7 @@ const propTypes = {
users: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
setEmojiStatus: PropTypes.func.isRequired,
clearAllEmojiStatus: PropTypes.func.isRequired,
clearAllReactions: PropTypes.func.isRequired,
roving: PropTypes.func.isRequired,
requestUserInformation: PropTypes.func.isRequired,
};
@ -196,6 +197,7 @@ class UserParticipants extends Component {
users,
compact,
clearAllEmojiStatus,
clearAllReactions,
currentUser,
meetingIsBreakout,
isMeetingMuteOnStart,
@ -216,6 +218,7 @@ class UserParticipants extends Component {
? (
<UserOptionsContainer {...{
clearAllEmojiStatus,
clearAllReactions,
meetingIsBreakout,
isMeetingMuteOnStart,
}}

View File

@ -21,6 +21,7 @@ const UserParticipantsContainer = (props) => {
setEmojiStatus,
setUserAway,
clearAllEmojiStatus,
clearAllReactions,
roving,
requestUserInformation,
} = UserListService;
@ -40,6 +41,7 @@ const UserParticipantsContainer = (props) => {
setEmojiStatus,
setUserAway,
clearAllEmojiStatus,
clearAllReactions,
roving,
requestUserInformation,
isReady,

View File

@ -21,6 +21,7 @@ const propTypes = {
toggleMuteAllUsers: PropTypes.func.isRequired,
toggleMuteAllUsersExceptPresenter: PropTypes.func.isRequired,
toggleStatus: PropTypes.func.isRequired,
toggleReactions: PropTypes.func.isRequired,
guestPolicy: PropTypes.string.isRequired,
meetingIsBreakout: PropTypes.bool.isRequired,
hasBreakoutRoom: PropTypes.bool.isRequired,
@ -41,6 +42,14 @@ const intlMessages = defineMessages({
id: 'app.userList.userOptions.clearAllDesc',
description: 'Clear all description',
},
clearAllReactionsLabel: {
id: 'app.userList.userOptions.clearAllReactionsLabel',
description: 'Clear all reactions label',
},
clearAllReactionsDesc: {
id: 'app.userList.userOptions.clearAllReactionsDesc',
description: 'Clear all reactions description',
},
muteAllLabel: {
id: 'app.userList.userOptions.muteAllLabel',
description: 'Mute all label',
@ -128,12 +137,14 @@ const intlMessages = defineMessages({
});
const USER_STATUS_ENABLED = Meteor.settings.public.userStatus.enabled;
const USER_REACTION_ENABLED = Meteor.settings.public.userReaction.enabled;
class UserOptions extends PureComponent {
constructor(props) {
super(props);
this.clearStatusId = uniqueId('list-item-');
this.clearReactionId = uniqueId('list-item-');
this.muteId = uniqueId('list-item-');
this.muteAllId = uniqueId('list-item-');
this.lockId = uniqueId('list-item-');
@ -199,6 +210,7 @@ class UserOptions extends PureComponent {
intl,
isMeetingMuted,
toggleStatus,
toggleReactions,
toggleMuteAllUsers,
toggleMuteAllUsersExceptPresenter,
meetingIsBreakout,
@ -284,6 +296,17 @@ class UserOptions extends PureComponent {
});
}
if (USER_REACTION_ENABLED) {
this.menuItems.push({
key: this.clearReactionId,
label: intl.formatMessage(intlMessages.clearAllReactionsLabel),
description: intl.formatMessage(intlMessages.clearAllReactionsDesc),
onClick: toggleReactions,
icon: 'clear_status',
divider: true,
});
}
if (canCreateBreakout) {
this.menuItems.push({
key: this.createBreakoutId,

View File

@ -17,6 +17,10 @@ const intlMessages = defineMessages({
id: 'app.userList.content.participants.options.clearedStatus',
description: 'Used in toast notification when emojis have been cleared',
},
clearReactionsMessage: {
id: 'app.userList.content.participants.options.clearedReactions',
description: 'Used in toast notification when reactions have been cleared',
},
});
const { dynamicGuestPolicy } = Meteor.settings.public.app;
@ -41,10 +45,19 @@ const UserOptionsContainer = (props) => {
export default injectIntl(withTracker((props) => {
const {
clearAllEmojiStatus,
clearAllReactions,
intl,
isMeetingMuteOnStart,
} = props;
const toggleReactions = () => {
clearAllReactions();
notify(
intl.formatMessage(intlMessages.clearReactionsMessage), 'info', 'clear_status',
);
};
const toggleStatus = () => {
clearAllEmojiStatus();
@ -81,6 +94,7 @@ export default injectIntl(withTracker((props) => {
}, 'moderator enabled meeting mute, all users muted except presenter');
},
toggleStatus,
toggleReactions,
isMeetingMuted: isMeetingMuteOnStart,
amIModerator: ActionsBarService.amIModerator(),
hasBreakoutRoom: UserListService.hasBreakoutRoom(),

View File

@ -12,7 +12,7 @@ export const usePreviousValue = (value) => {
ref.current = value;
});
return ref.current;
}
};
export default {
usePreviousValue,

View File

@ -14,7 +14,6 @@ import Settings from '/imports/ui/services/settings';
const ENABLE_WEBCAM_SELECTOR_BUTTON = Meteor.settings.public.app.enableWebcamSelectorButton;
const ENABLE_CAMERA_BRIGHTNESS = Meteor.settings.public.app.enableCameraBrightness;
const isSelfViewDisabled = Settings.application.selfViewDisable;
const intlMessages = defineMessages({
videoSettings: {

View File

@ -146,9 +146,9 @@ public:
enabled: false
# If true, positions the icon next to the screenshare button, if false positions it where BBB had raisedHand button up to BBB 2.6 (right-hand bottom corner in LRT)
centered: true
interactionsButton:
reactionsButton:
# Enables the new raiseHand icon inside of the reaction menu (introduced in BBB 2.7)
# If both interactionsButton and raiseHandActionButton are enabled, interactionsButton takes precedence.
# If both reactionsButton and raiseHandActionButton are enabled, reactionsButton takes precedence.
enabled: true
# If enabled, before joining microphone the client will perform a trickle
# ICE against Kurento and use the information about successfull

View File

@ -166,6 +166,8 @@
"app.userList.userOptions.muteAllDesc": "Mutes all users in the meeting",
"app.userList.userOptions.clearAllLabel": "Clear all status icons",
"app.userList.userOptions.clearAllDesc": "Clears all status icons from users",
"app.userList.userOptions.clearAllReactionsLabel": "Clear all reactions",
"app.userList.userOptions.clearAllReactionsDesc": "Clears all reaction emojis from users",
"app.userList.userOptions.muteAllExceptPresenterLabel": "Mute all users except presenter",
"app.userList.userOptions.muteAllExceptPresenterDesc": "Mutes all users in the meeting except the presenter",
"app.userList.userOptions.unmuteAllLabel": "Turn off meeting mute",
@ -182,6 +184,7 @@
"app.userList.userOptions.hideUserList": "User list is now hidden for viewers",
"app.userList.userOptions.webcamsOnlyForModerator": "Only moderators are able to see viewers' webcams (due to lock settings)",
"app.userList.content.participants.options.clearedStatus": "Cleared all user status",
"app.userList.content.participants.options.clearedReactions": "Cleared all user reactions",
"app.userList.userOptions.enableCam": "Viewers' webcams are enabled",
"app.userList.userOptions.enableMic": "Viewers' microphones are enabled",
"app.userList.userOptions.enablePrivChat": "Private chat is enabled",
@ -614,8 +617,8 @@
"app.talkingIndicator.moreThanMaxIndicatorsWereTalking": "{0}+ were talking",
"app.talkingIndicator.wasTalking": "{0} stopped talking",
"app.actionsBar.actionsDropdown.actionsLabel": "Actions",
"app.actionsBar.actionsDropdown.activateTimerLabel": "Activate stopwatch",
"app.actionsBar.actionsDropdown.deactivateTimerLabel": "Deactivate stopwatch",
"app.actionsBar.actionsDropdown.activateTimerStopwatchLabel": "Activate timer/stopwatch",
"app.actionsBar.actionsDropdown.deactivateTimerStopwatchLabel": "Deactivate timer/stopwatch",
"app.actionsBar.actionsDropdown.presentationLabel": "Upload/Manage presentations",
"app.actionsBar.actionsDropdown.initPollLabel": "Initiate a poll",
"app.actionsBar.actionsDropdown.desktopShareLabel": "Share your screen",
@ -635,10 +638,9 @@
"app.actionsBar.actionsDropdown.takePresenterDesc": "Assign yourself as the new presenter",
"app.actionsBar.actionsDropdown.selectRandUserLabel": "Select random user",
"app.actionsBar.actionsDropdown.selectRandUserDesc": "Chooses a user from available viewers at random",
"app.actionsBar.interactions.interactions": "Interactions",
"app.actionsBar.interactions.raiseHand": "Raise your hand",
"app.actionsBar.interactions.lowHand": "Lower your hand",
"app.actionsBar.interactions.addReaction": "Add a reaction",
"app.actionsBar.reactions.reactionsButtonLabel": "Reactions bar",
"app.actionsBar.reactions.raiseHand": "Raise your hand",
"app.actionsBar.reactions.lowHand": "Lower your hand",
"app.actionsBar.emojiMenu.statusTriggerLabel": "Set status",
"app.actionsBar.emojiMenu.awayLabel": "Away",
"app.actionsBar.emojiMenu.awayDesc": "Change your status to away",

View File

@ -482,16 +482,9 @@
"app.actionsBar.actionsDropdown.takePresenterDesc": "Assume o papel de apresentador",
"app.actionsBar.actionsDropdown.selectRandUserLabel": "Selecione um participante aleatoriamente",
"app.actionsBar.actionsDropdown.selectRandUserDesc": "Escolhe aleatoriamente um participante da lista",
"app.actionsBar.interactions.interactions": "Interações",
"app.actionsBar.interactions.interactionsAdvancedButton": "Abrir o menu de interações",
"app.actionsBar.interactions.raiseHand": "Levantar a mão",
"app.actionsBar.interactions.lowHand": "Abaixar a mão",
"app.actionsBar.interactions.writeQuestion": "Escrever uma pergunta",
"app.actionsBar.interactions.addReaction": "Adicionar reação",
"app.actionsBar.interactions.status": "Status",
"app.actionsBar.interactions.present": "Presente",
"app.actionsBar.interactions.away": "Ausente",
"app.actionsBar.interactions.back": "Voltar",
"app.actionsBar.reactions.reactionsButtonLabel": "Reações",
"app.actionsBar.reactions.raiseHand": "Levantar a mão",
"app.actionsBar.reactions.lowHand": "Abaixar a mão",
"app.actionsBar.emojiMenu.statusTriggerLabel": "Definir status",
"app.actionsBar.emojiMenu.awayLabel": "Ausente",
"app.actionsBar.emojiMenu.awayDesc": "Mudar seu status para ausente",

View File

@ -40,6 +40,8 @@ async function generateSettingsData(page) {
webcamSharingEnabled: settingsData.kurento.enableVideo,
skipVideoPreview: settingsData.kurento.skipVideoPreview,
skipVideoPreviewOnFirstJoin: settingsData.kurento.skipVideoPreviewOnFirstJoin,
// User
userStatusEnabled: settingsData.userStatus.enabled,
}
return settings;

View File

@ -31,7 +31,7 @@ exports.docTitle = docTitle;
exports.clientTitle = `userdata-bbb_client_title=${docTitle}`;
exports.askForFeedbackOnLogout = 'userdata-bbb_ask_for_feedback_on_logout=true';
exports.displayBrandingArea = 'userdata-bbb_display_branding_area=true';
exports.logo = 'logo=https://bigbluebutton.org/wp-content/themes/bigbluebutton/library/images/bigbluebutton-logo.png';
exports.logo = 'logo=https://bigbluebutton.org/wp-content/uploads/2021/01/BigBlueButton_icon.svg.png';
exports.enableVideo = 'userdata-bbb_enable_video=false';
exports.autoShareWebcam = 'userdata-bbb_auto_share_webcam=true';
exports.multiUserPenOnly = 'userdata-bbb_multi_user_pen_only=true';

View File

@ -69,7 +69,7 @@ class Presentation extends MultiUsers {
await uploadSinglePresentation(this.modPage, e.pdfFileName, UPLOAD_PDF_WAIT_TIME);
// wait until the notifications disappear
await this.modPage.hasElement(e.presentationStatusInfo, ELEMENT_WAIT_LONGER_TIME);
await this.modPage.waitAndClick(e.smallToastMsg);
await this.modPage.wasRemoved(e.smallToastMsg, ELEMENT_WAIT_LONGER_TIME);
await this.userPage.wasRemoved(e.presentationStatusInfo);
await this.userPage.wasRemoved(e.smallToastMsg);
@ -151,8 +151,8 @@ class Presentation extends MultiUsers {
await this.modPage.waitAndClick(e.presentationOptionsDownloadBtn);
await this.modPage.waitAndClick(e.sendPresentationInCurrentStateBtn);
await this.modPage.hasElement(e.downloadPresentationToast);
await this.modPage.hasElement(e.smallToastMsg, ELEMENT_WAIT_LONGER_TIME);
await this.userPage.hasElement(e.downloadPresentation, ELEMENT_WAIT_EXTRA_LONG_TIME);
await this.modPage.hasElement(e.smallToastMsg, ELEMENT_WAIT_EXTRA_LONG_TIME);
await this.userPage.hasElement(e.downloadPresentation);
const downloadPresentationLocator = this.userPage.getLocator(e.downloadPresentation);
await this.userPage.handleDownload(downloadPresentationLocator, testInfo);
}
@ -171,7 +171,6 @@ class Presentation extends MultiUsers {
}
async uploadAndRemoveAllPresentations() {
await waitAndClearDefaultPresentationNotification(this.modPage);
await uploadSinglePresentation(this.modPage, e.uploadPresentationFileName);
const modSlides1 = await getSlideOuterHtml(this.modPage);

View File

@ -1,7 +1,7 @@
const { expect } = require('@playwright/test');
const path = require('path');
const e = require('../core/elements');
const { ELEMENT_WAIT_LONGER_TIME, UPLOAD_PDF_WAIT_TIME } = require('../core/constants');
const { UPLOAD_PDF_WAIT_TIME, ELEMENT_WAIT_EXTRA_LONG_TIME, ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
async function checkSvgIndex(test, element) {
const check = await test.page.evaluate(([el, slideImg]) => {
@ -32,6 +32,7 @@ async function uploadSinglePresentation(test, fileName, uploadTimeout = UPLOAD_P
await test.hasText('body', e.statingUploadPresentationToast);
await test.waitAndClick(e.confirmManagePresentation);
await test.hasElement(e.presentationStatusInfo, ELEMENT_WAIT_LONGER_TIME);
await test.page.waitForFunction(([selector, firstSlideSrc]) => {
const currentSrc = document.querySelector(selector).src;
return currentSrc != firstSlideSrc;
@ -40,7 +41,7 @@ async function uploadSinglePresentation(test, fileName, uploadTimeout = UPLOAD_P
});
}
async function uploadMultiplePresentations(test, fileNames, uploadTimeout = ELEMENT_WAIT_LONGER_TIME) {
async function uploadMultiplePresentations(test, fileNames, uploadTimeout = ELEMENT_WAIT_EXTRA_LONG_TIME) {
await test.waitAndClick(e.actions);
await test.waitAndClick(e.managePresentations);
await test.waitForSelector(e.fileUpload);

View File

@ -1,7 +1,9 @@
const { default: test } = require('@playwright/test');
const Page = require('../core/page');
const { setStatus } = require('./util');
const { waitAndClearNotification, waitAndClearDefaultPresentationNotification } = require('../notifications/util');
const e = require('../core/elements');
const { getSettings } = require('../core/settings');
class Status extends Page {
constructor(browser, page) {
@ -9,6 +11,9 @@ class Status extends Page {
}
async changeUserStatus() {
const { userStatusEnabled } = getSettings();
test.fail(!userStatusEnabled, 'User status is disabled');
await waitAndClearDefaultPresentationNotification(this);
await setStatus(this, e.applaud);
await this.waitForSelector(e.smallToastMsg);

View File

@ -19,6 +19,8 @@ Here's a breakdown of what's new in 2.7.
We have enhanced the layout which is focused on webcams by providing a visual representation of each participant. This way whether a webcam was shared or not, you can more easily be aware of who is speaking, who is present etc.
![Grid Layout](/img/27-grid-layout.png)
#### Camera as content
In hybrid learning (and not only) there is a frequently a need for displaying a physical whiteboard or draw the attention of students to a specific physical area. We now support using a webcam as the main content to occupy the presentation area.
@ -41,16 +43,36 @@ In BigBlueButton 2.4 and 2.5 we supported optional downloading of the entire pre
![You can enable original presentation downloading from the upload dialog](/img/27-enable-download-orig-presentation.png)
The download button is the same as in BigBlueButton 2.5!
The download button is overlayed on top of the presentation.
![Once downloading is enabled, everyone in the room can use it](/img/27-download-orig-presentation.png)
#### Timer and stopwatch
We have added the long requested option to display a count down (timer) or a count up (stopwatch) in the session. They are displayed to all participants and there is an audio notification when the timer elapses.
![The timer can be activated from the plus button menu](/img/27-activate-timer.png)
Setting up a timer for four minutes.
![Setting up a 4 minutes timer](/img/27-timer-4mins-start.png)
Everyone sees the timer as it counts down.
![Everyone seeing 4 minutes timer](/img/27-timer-4mins.png)
### Engagement
#### Reaction Bar
#### Reactions Bar
The Reaction Bar aims to make it much easier for students to respond with emojis to the teacher. The emoji is displayed in the user avatar area for 1 minute (configurable).
The Reactions Bar aims to make it much easier for students to respond with emojis to the teacher. The emoji is displayed in the user avatar area for 1 minute (configurable). The bar remains visible once activated, and the emoji selected remains visible until it times out or is unselected. Modifying the configuration options (settings.yml) an additional set of emojis can be displayed, or the Reactions Bar can be substituted with the Status selecter we used in BigBlueButton 2.6 and prior.
![Reactions Bar remains visible once activated](/img/27-reactions-bar.png)
Others see your reactions in the participants list.
![Others see your reactions in the participants list](/img/27-reactions-thumbs-up.png)
<!-- ### Analytics -->
@ -96,6 +118,7 @@ For full details on what is new in BigBlueButton 2.7, see the release notes.
Recent releases:
- [2.7.0-beta.1](https://github.com/bigbluebutton/bigbluebutton/releases/tag/v2.7.0-beta.1)
- [2.7.0-alpha.3](https://github.com/bigbluebutton/bigbluebutton/releases/tag/v2.7.0-alpha.3)
- [2.7.0-alpha.2](https://github.com/bigbluebutton/bigbluebutton/releases/tag/v2.7.0-alpha.2)
- [2.7.0-alpha.1](https://github.com/bigbluebutton/bigbluebutton/releases/tag/v2.7.0-alpha.1)

42
docs/package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"@cmfcmf/docusaurus-search-local": "^0.11.0",
"@docusaurus/core": "2.2.0",
"@docusaurus/plugin-client-redirects": "2.2.0",
"@docusaurus/preset-classic": "2.2.0",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
@ -20,7 +21,7 @@
"devDependencies": {
"@docusaurus/module-type-aliases": "^2.2.0",
"@tsconfig/docusaurus": "^1.0.6",
"typescript": "^4.9.3"
"typescript": "^4.9.4"
},
"engines": {
"node": ">=16.14"
@ -2206,6 +2207,29 @@
"react-dom": "*"
}
},
"node_modules/@docusaurus/plugin-client-redirects": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-2.2.0.tgz",
"integrity": "sha512-psBoWi+cbc2I+VPkKJlcZ12tRN3xiv22tnZfNKyMo18iSY8gr4B6Q0G2KZXGPgNGJ/6gq7ATfgDK6p9h9XRxMQ==",
"dependencies": {
"@docusaurus/core": "2.2.0",
"@docusaurus/logger": "2.2.0",
"@docusaurus/utils": "2.2.0",
"@docusaurus/utils-common": "2.2.0",
"@docusaurus/utils-validation": "2.2.0",
"eta": "^1.12.3",
"fs-extra": "^10.1.0",
"lodash": "^4.17.21",
"tslib": "^2.4.0"
},
"engines": {
"node": ">=16.14"
},
"peerDependencies": {
"react": "^16.8.4 || ^17.0.0",
"react-dom": "^16.8.4 || ^17.0.0"
}
},
"node_modules/@docusaurus/plugin-content-blog": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-2.2.0.tgz",
@ -14047,6 +14071,22 @@
"react-loadable": "npm:@docusaurus/react-loadable@5.5.2"
}
},
"@docusaurus/plugin-client-redirects": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-2.2.0.tgz",
"integrity": "sha512-psBoWi+cbc2I+VPkKJlcZ12tRN3xiv22tnZfNKyMo18iSY8gr4B6Q0G2KZXGPgNGJ/6gq7ATfgDK6p9h9XRxMQ==",
"requires": {
"@docusaurus/core": "2.2.0",
"@docusaurus/logger": "2.2.0",
"@docusaurus/utils": "2.2.0",
"@docusaurus/utils-common": "2.2.0",
"@docusaurus/utils-validation": "2.2.0",
"eta": "^1.12.3",
"fs-extra": "^10.1.0",
"lodash": "^4.17.21",
"tslib": "^2.4.0"
}
},
"@docusaurus/plugin-content-blog": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-2.2.0.tgz",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
docs/static/img/27-activate-timer.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 59 KiB

BIN
docs/static/img/27-grid-layout.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

BIN
docs/static/img/27-reactions-bar.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
docs/static/img/27-timer-4mins-start.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
docs/static/img/27-timer-4mins.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB