diff --git a/lang/main.json b/lang/main.json index fa4b65cac3..327799bc70 100644 --- a/lang/main.json +++ b/lang/main.json @@ -1108,6 +1108,7 @@ "signedIn": "Currently accessing calendar events for {{email}}. Click the Disconnect button below to stop accessing calendar events.", "title": "Calendar" }, + "chatWithPermissions": "Chat requires permission", "desktopShareFramerate": "Desktop sharing frame rate", "desktopShareHighFpsWarning": "A higher frame rate for desktop sharing might affect your bandwidth. You need to restart the screen share for the new settings to take effect.", "desktopShareWarning": "You need to restart the screen share for the new settings to take effect.", diff --git a/react/features/chat/reducer.ts b/react/features/chat/reducer.ts index ae84df2169..8cb4fd679c 100644 --- a/react/features/chat/reducer.ts +++ b/react/features/chat/reducer.ts @@ -15,8 +15,10 @@ import { SET_PRIVATE_MESSAGE_RECIPIENT } from './actionTypes'; import { IMessage } from './types'; +import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes'; const DEFAULT_STATE = { + groupChatWithPermissions: false, isOpen: false, isPollsTabFocused: false, lastReadMessage: undefined, @@ -29,6 +31,7 @@ const DEFAULT_STATE = { }; export interface IChatState { + groupChatWithPermissions: boolean; isLobbyChatActive: boolean; isOpen: boolean; isPollsTabFocused: boolean; @@ -203,6 +206,16 @@ ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, ac isLobbyChatActive: false, lobbyMessageRecipient: undefined }; + case UPDATE_CONFERENCE_METADATA: { + const { metadata } = action; + + if (metadata?.permissions) { + return { + ...state, + groupChatWithPermissions: Boolean(metadata.permissions.groupChatRestricted) + }; + } + } } return state; diff --git a/react/features/dynamic-branding/middleware.any.ts b/react/features/dynamic-branding/middleware.any.ts index 575adaa697..716aa858fe 100644 --- a/react/features/dynamic-branding/middleware.any.ts +++ b/react/features/dynamic-branding/middleware.any.ts @@ -1,10 +1,16 @@ import MiddlewareRegistry from '../base/redux/MiddlewareRegistry'; -import { SET_DYNAMIC_BRANDING_DATA } from './actionTypes'; +import { SET_DYNAMIC_BRANDING_DATA, SET_DYNAMIC_BRANDING_READY } from './actionTypes'; import { fetchCustomIcons } from './functions.any'; import logger from './logger'; +import { getCurrentConference } from '../base/conference/functions'; +import { getLocalParticipant, isLocalParticipantModerator } from '../base/participants/functions'; +import { PARTICIPANT_ROLE_CHANGED } from '../base/participants/actionTypes'; +import { PARTICIPANT_ROLE } from '../base/participants/constants'; +import { IStateful } from '../base/app/types'; +import { toState } from '../base/redux/functions'; -MiddlewareRegistry.register(() => next => action => { +MiddlewareRegistry.register(store => next => action => { switch (action.type) { case SET_DYNAMIC_BRANDING_DATA: { const { customIcons } = action.value; @@ -23,7 +29,75 @@ MiddlewareRegistry.register(() => next => action => { break; } + + case PARTICIPANT_ROLE_CHANGED: { + const state = store.getState(); + const localParticipant = getLocalParticipant(state); + + if (localParticipant?.id !== action.participant.id + && action.participant.role !== PARTICIPANT_ROLE.MODERATOR) { + break; + } + + maybeUpdatePermissions(state); + + break; + } + + case SET_DYNAMIC_BRANDING_READY: { + const state = store.getState(); + + if (!isLocalParticipantModerator(state)) { + break; + } + + maybeUpdatePermissions(state); + + break; + } } return next(action); }); + +/** + * Updates the permissions metadata for the current conference if the local participant is a moderator. + * + * @param {Object|Function} stateful - Object or function that can be resolved + * to the Redux state. + * @returns {void} + */ +function maybeUpdatePermissions(stateful: IStateful): void { + const { + groupChatRequiresPermission, + pollCreationRequiresPermission + } = toState(stateful)['features/dynamic-branding']; + + if (groupChatRequiresPermission || pollCreationRequiresPermission) { + const conference = getCurrentConference(stateful); + + if (!conference) { + return; + } + + const permissions: { + groupChatRestricted?: boolean; + pollCreationRestricted?: boolean; + } = conference.getMetadataHandler().getMetadata().permissions || {}; + let sendUpdate = false; + + if (groupChatRequiresPermission && !permissions.groupChatRestricted) { + permissions.groupChatRestricted = true; + sendUpdate = true; + } + + if (pollCreationRequiresPermission && !permissions.pollCreationRestricted) { + permissions.pollCreationRestricted = true; + sendUpdate = true; + } + + if (sendUpdate) { + conference.getMetadataHandler().setMetadata('permissions', permissions); + } + } +} diff --git a/react/features/settings/actions.web.ts b/react/features/settings/actions.web.ts index 061b179715..2816042b8c 100644 --- a/react/features/settings/actions.web.ts +++ b/react/features/settings/actions.web.ts @@ -189,6 +189,17 @@ export function submitModeratorTab(newState: any) { dispatch(setStartMutedPolicy( newState.startAudioMuted, newState.startVideoMuted)); } + + if (newState.chatWithPermissionsEnabled !== currentState.chatWithPermissionsEnabled) { + const { conference } = getState()['features/base/conference']; + + const currentPermissions = conference?.getMetadataHandler().getMetadata().permissions || {}; + + conference?.getMetadataHandler().setMetadata('permissions', { + ...currentPermissions, + groupChatRestricted: newState.chatWithPermissionsEnabled + }); + } }; } diff --git a/react/features/settings/components/native/ModeratorSection.tsx b/react/features/settings/components/native/ModeratorSection.tsx index b1f614b0df..f61593b375 100644 --- a/react/features/settings/components/native/ModeratorSection.tsx +++ b/react/features/settings/components/native/ModeratorSection.tsx @@ -18,6 +18,7 @@ import FormSection from './FormSection'; const ModeratorSection = () => { const dispatch = useDispatch(); const { + chatWithPermissionsEnabled, followMeActive, followMeEnabled, followMeRecorderActive, @@ -52,6 +53,16 @@ const ModeratorSection = () => { dispatch(updateSettings({ soundsReactions: enabled })); }, [ dispatch, updateSettings, setStartReactionsMuted ]); + const { conference } = useSelector((state: IReduxState) => state['features/base/conference']); + const onChatWithPermissionsToggled = useCallback((enabled?: boolean) => { + const currentPermissions = conference?.getMetadataHandler().getMetadata().permissions || {}; + + conference?.getMetadataHandler().setMetadata('permissions', { + ...currentPermissions, + groupChatRestricted: enabled + }); + }, [ dispatch, conference ]); + const followMeRecorderChecked = followMeRecorderEnabled && !followMeRecorderActive; const moderationSettings = useMemo(() => { @@ -85,7 +96,12 @@ const ModeratorSection = () => { label: 'settings.startReactionsMuted', state: startReactionsMuted, onChange: onStartReactionsMutedToggled - } + }, + { + label: 'settings.chatWithPermissions', + state: chatWithPermissionsEnabled, + onChange: onChatWithPermissionsToggled + }, ]; if (disableReactionsModeration) { diff --git a/react/features/settings/components/web/ModeratorTab.tsx b/react/features/settings/components/web/ModeratorTab.tsx index 372868432c..26ea612306 100644 --- a/react/features/settings/components/web/ModeratorTab.tsx +++ b/react/features/settings/components/web/ModeratorTab.tsx @@ -14,11 +14,21 @@ import Checkbox from '../../../base/ui/components/web/Checkbox'; */ export interface IProps extends AbstractDialogTabProps, WithTranslation { + /** + * Whether the user has selected the chat with permissions feature to be enabled. + */ + chatWithPermissionsEnabled: boolean; + /** * CSS classes object. */ classes?: Partial, string>>; + /** + * Whether to hide chat with permissions. + */ + disableChatWithPermissions: boolean; + /** * If set hides the reactions moderation setting. */ @@ -103,6 +113,7 @@ class ModeratorTab extends AbstractDialogTab { this._onStartReactionsMutedChanged = this._onStartReactionsMutedChanged.bind(this); this._onFollowMeEnabledChanged = this._onFollowMeEnabledChanged.bind(this); this._onFollowMeRecorderEnabledChanged = this._onFollowMeRecorderEnabledChanged.bind(this); + this._onChatWithPermissionsChanged = this._onChatWithPermissionsChanged.bind(this); } /** @@ -170,6 +181,17 @@ class ModeratorTab extends AbstractDialogTab { }); } + /** + * Callback invoked to select if chat with permissions should be activated. + * + * @param {Object} e - The key event to handle. + * + * @returns {void} + */ + _onChatWithPermissionsChanged({ target: { checked } }: React.ChangeEvent) { + super._onChange({ chatWithPermissionsEnabled: checked }); + } + /** * Implements React's {@link Component#render()}. * @@ -178,6 +200,8 @@ class ModeratorTab extends AbstractDialogTab { */ override render() { const { + chatWithPermissionsEnabled, + disableChatWithPermissions, disableReactionsModeration, followMeActive, followMeEnabled, @@ -232,6 +256,13 @@ class ModeratorTab extends AbstractDialogTab { label = { t('settings.startReactionsMuted') } name = 'start-reactions-muted' onChange = { this._onStartReactionsMutedChanged } /> } + { !disableChatWithPermissions + && } ); } diff --git a/react/features/settings/components/web/SettingsDialog.tsx b/react/features/settings/components/web/SettingsDialog.tsx index 434fb95b8d..74a949b856 100644 --- a/react/features/settings/components/web/SettingsDialog.tsx +++ b/react/features/settings/components/web/SettingsDialog.tsx @@ -252,6 +252,7 @@ function _mapStateToProps(state: IReduxState, ownProps: any) { return { ...newProps, + chatWithPermissionsEnabled: tabState?.chatWithPermissionsEnabled, followMeEnabled: tabState?.followMeEnabled, followMeRecorderEnabled: tabState?.followMeRecorderEnabled, startAudioMuted: tabState?.startAudioMuted, diff --git a/react/features/settings/components/web/VirtualBackgroundTab.tsx b/react/features/settings/components/web/VirtualBackgroundTab.tsx index 6de6d81a89..687c6694d5 100644 --- a/react/features/settings/components/web/VirtualBackgroundTab.tsx +++ b/react/features/settings/components/web/VirtualBackgroundTab.tsx @@ -47,7 +47,7 @@ const styles = () => { */ class VirtualBackgroundTab extends AbstractDialogTab { /** - * Initializes a new {@code ModeratorTab} instance. + * Initializes a new {@code VirtualBackgroundTab} instance. * * @param {Object} props - The read-only properties with which the new * instance is to be initialized. diff --git a/react/features/settings/functions.any.ts b/react/features/settings/functions.any.ts index 20f8195aff..32edeec869 100644 --- a/react/features/settings/functions.any.ts +++ b/react/features/settings/functions.any.ts @@ -138,14 +138,18 @@ export function getModeratorTabProps(stateful: IStateful) { startVideoMutedPolicy, startReactionsMuted } = state['features/base/conference']; + const { groupChatWithPermissions } = state['features/chat']; const { disableReactionsModeration } = state['features/base/config']; const followMeActive = isFollowMeActive(state); const followMeRecorderActive = isFollowMeRecorderActive(state); const showModeratorSettings = shouldShowModeratorSettings(state); + const disableChatWithPermissions = !conference?.getMetadataHandler().getMetadata().allownersEnabled; // The settings sections to display. return { + chatWithPermissionsEnabled: Boolean(groupChatWithPermissions), showModeratorSettings: Boolean(conference && showModeratorSettings), + disableChatWithPermissions: Boolean(disableChatWithPermissions), disableReactionsModeration: Boolean(disableReactionsModeration), followMeActive: Boolean(conference && followMeActive), followMeEnabled: Boolean(conference && followMeEnabled), diff --git a/resources/prosody-plugins/mod_filter_messages.lua b/resources/prosody-plugins/mod_filter_messages.lua new file mode 100644 index 0000000000..8e9716afaf --- /dev/null +++ b/resources/prosody-plugins/mod_filter_messages.lua @@ -0,0 +1,42 @@ +-- enable under the main muc module +-- a module that will filter group messages based on features (jitsi_meet_context_features) +-- when requested via metadata (permissions.groupChatRestricted) +local util = module:require 'util'; +local get_room_from_jid = util.get_room_from_jid; +local st = require 'util.stanza'; + +local function on_message(event) + local stanza = event.stanza; + local body = stanza:get_child('body'); + local session = event.origin; + + if not body or not session then + -- we ignore messages without body - lobby, polls ... + return; + end + + -- get room name with tenant and find room. + -- this should already been through domain mapper and this should be the real room jid [tenant]name format + local room = get_room_from_jid(stanza.attr.to); + if not room then + module:log('warn', 'No room found found for %s', stanza.attr.to); + return; + end + + if room.jitsiMetadata and room.jitsiMetadata.permissions + and room.jitsiMetadata.permissions.groupChatRestricted + and not is_feature_allowed('send-groupchat', session.jitsi_meet_context_features) then + + local reply = st.error_reply(stanza, 'cancel', 'not-allowed', 'Sending group messages not allowed'); + if session.type == 's2sin' or session.type == 's2sout' then + reply.skipMapping = true; + end + module:send(reply); + + -- let's filter this message + return true; + end +end + +module:hook('message/bare', on_message); -- room messages +module:hook('jitsi-visitor-groupchat-pre-route', on_message); -- visitors messages diff --git a/resources/prosody-plugins/mod_jitsi_permissions.lua b/resources/prosody-plugins/mod_jitsi_permissions.lua index a595880a3a..1e0899c14a 100644 --- a/resources/prosody-plugins/mod_jitsi_permissions.lua +++ b/resources/prosody-plugins/mod_jitsi_permissions.lua @@ -100,10 +100,13 @@ end -- In case permissions were granted we want to send the granted permissions in all cases except when the user is -- using token that has features pre-defined (authentication is 'token'). function filter_stanza(stanza, session) - local bare_to = jid.bare(stanza.attr.to); if not stanza.attr or not stanza.attr.to or stanza.name ~= 'presence' - or stanza.attr.type == 'unavailable' - or ends_with(stanza.attr.from, '/focus') or is_admin(bare_to) then + or stanza.attr.type == 'unavailable' or ends_with(stanza.attr.from, '/focus') then + return stanza; + end + + local bare_to = jid.bare(stanza.attr.to); + if is_admin(bare_to) then return stanza; end diff --git a/resources/prosody-plugins/mod_polls.lua b/resources/prosody-plugins/mod_polls.lua index a87e8f5c66..8d9865109e 100644 --- a/resources/prosody-plugins/mod_polls.lua +++ b/resources/prosody-plugins/mod_polls.lua @@ -95,6 +95,13 @@ module:hook('jitsi-endpoint-message-received', function(event) return true; end + if room.jitsiMetadata and room.jitsiMetadata.permissions + and room.jitsiMetadata.permissions.pollCreationRestricted + and not is_feature_allowed('create-polls', origin.jitsi_meet_context_features) then + origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Creation of polls not allowed for user')); + return true; + end + local answers = {} local compact_answers = {} for i, name in ipairs(data.answers) do diff --git a/resources/prosody-plugins/mod_visitors.lua b/resources/prosody-plugins/mod_visitors.lua index 11241b1497..81ea5e1ab2 100644 --- a/resources/prosody-plugins/mod_visitors.lua +++ b/resources/prosody-plugins/mod_visitors.lua @@ -314,6 +314,11 @@ process_host_module(main_muc_component_config, function(host_module, host) return; end + if host_module:fire_event('jitsi-visitor-groupchat-pre-route', event) then + -- message filtered + return; + end + -- a message from visitor occupant of known visitor node stanza.attr.from = to; for _, o in room:each_occupant() do