diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5ed62affbc..8859f175a4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,14 @@ + + + + + + diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index 7b908ca7f9..970608cece 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -277,6 +277,7 @@ const Post = ({ } else if (isCallsPost && !hasBeenDeleted) { body = ( ); diff --git a/app/constants/index.ts b/app/constants/index.ts index 924a5c4fdd..4305c740d5 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -17,6 +17,7 @@ import Files from './files'; import General from './general'; import Integrations from './integrations'; import Launch from './launch'; +import License from './license'; import List from './list'; import Navigation from './navigation'; import Network from './network'; @@ -52,6 +53,7 @@ export { General, Integrations, Launch, + License, List, Navigation, Network, diff --git a/app/constants/license.ts b/app/constants/license.ts new file mode 100644 index 0000000000..7016c3d57a --- /dev/null +++ b/app/constants/license.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export default { + SKU_SHORT_NAME: { + E10: 'E10', + E20: 'E20', + Starter: 'starter', + Professional: 'professional', + Enterprise: 'enterprise', + }, +}; diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 82712d0b95..926424da24 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -71,7 +71,7 @@ export const loadConfig = async (serverUrl: string, force = false) => { return {error}; } - const nextConfig = {...config, ...data, last_retrieved_at: now}; + const nextConfig = {...data, last_retrieved_at: now}; setConfig(serverUrl, nextConfig); return {data: nextConfig}; }; @@ -189,7 +189,10 @@ export const checkIsCallsPluginEnabled = async (serverUrl: string) => { } const enabled = data.findIndex((m) => m.id === Calls.PluginId) !== -1; - setPluginEnabled(serverUrl, enabled); + const curEnabled = getCallsConfig(serverUrl).pluginEnabled; + if (enabled !== curEnabled) { + setPluginEnabled(serverUrl, enabled); + } return {data: enabled}; }; diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts new file mode 100644 index 0000000000..5c9ed9dfac --- /dev/null +++ b/app/products/calls/alerts.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Alert} from 'react-native'; + +import type {IntlShape} from 'react-intl'; + +export const showLimitRestrictedAlert = (maxParticipants: number, intl: IntlShape) => { + const title = intl.formatMessage({ + id: 'mobile.calls_participant_limit_title', + defaultMessage: 'Participant limit reached', + }); + const message = intl.formatMessage({ + id: 'mobile.calls_limit_msg', + defaultMessage: 'The maximum number of participants per call is {maxParticipants}. Contact your System Admin to increase the limit.', + }, {maxParticipants}); + const ok = intl.formatMessage({ + id: 'mobile.calls_ok', + defaultMessage: 'Okay', + }); + + Alert.alert( + title, + message, + [ + { + text: ok, + style: 'cancel', + }, + ], + ); +}; diff --git a/app/products/calls/components/calls_custom_message/calls_custom_message.tsx b/app/products/calls/components/calls_custom_message/calls_custom_message.tsx index 70916472d0..e9bd83ba71 100644 --- a/app/products/calls/components/calls_custom_message/calls_custom_message.tsx +++ b/app/products/calls/components/calls_custom_message/calls_custom_message.tsx @@ -6,6 +6,7 @@ import React from 'react'; import {useIntl} from 'react-intl'; import {Text, TouchableOpacity, View} from 'react-native'; +import {showLimitRestrictedAlert} from '@calls/alerts'; import leaveAndJoinWithAlert from '@calls/components/leave_and_join_alert'; import CompassIcon from '@components/compass_icon'; import FormattedRelativeTime from '@components/formatted_relative_time'; @@ -16,6 +17,7 @@ import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {displayUsername, getUserTimezone} from '@utils/user'; +import type {LimitRestrictedInfo} from '@calls/observers'; import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; @@ -28,6 +30,7 @@ type Props = { currentCallChannelId?: string; leaveChannelName?: string; joinChannelName?: string; + limitRestrictedInfo?: LimitRestrictedInfo; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -62,10 +65,16 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { joinCallButtonText: { color: 'white', }, + joinCallButtonTextRestricted: { + color: changeOpacity(theme.centerChannelColor, 0.32), + }, joinCallButtonIcon: { color: 'white', marginRight: 5, }, + joinCallButtonIconRestricted: { + color: changeOpacity(theme.centerChannelColor, 0.32), + }, startedText: { color: theme.centerChannelColor, fontWeight: 'bold', @@ -78,6 +87,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { alignItems: 'center', alignContent: 'center', }, + joinCallButtonRestricted: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + }, timeText: { color: theme.centerChannelColor, }, @@ -96,7 +108,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { export const CallsCustomMessage = ({ post, currentUser, author, isMilitaryTime, teammateNameDisplay, - currentCallChannelId, leaveChannelName, joinChannelName, + currentCallChannelId, leaveChannelName, joinChannelName, limitRestrictedInfo, }: Props) => { const intl = useIntl(); const theme = useTheme(); @@ -106,12 +118,18 @@ export const CallsCustomMessage = ({ const confirmToJoin = Boolean(currentCallChannelId && currentCallChannelId !== post.channelId); const alreadyInTheCall = Boolean(currentCallChannelId && currentCallChannelId === post.channelId); + const isLimitRestricted = Boolean(limitRestrictedInfo?.limitRestricted); const joinHandler = () => { if (alreadyInTheCall) { return; } + if (isLimitRestricted) { + showLimitRestrictedAlert(limitRestrictedInfo!.maxParticipants, intl); + return; + } + leaveAndJoinWithAlert(intl, serverUrl, post.channelId, leaveChannelName || '', joinChannelName || '', confirmToJoin, false); }; @@ -157,6 +175,7 @@ export const CallsCustomMessage = ({ ); } + const joinTextStyle = [style.joinCallButtonText, isLimitRestricted && style.joinCallButtonTextRestricted]; return ( { alreadyInTheCall && } { @@ -200,7 +219,7 @@ export const CallsCustomMessage = ({ } diff --git a/app/products/calls/components/calls_custom_message/index.ts b/app/products/calls/components/calls_custom_message/index.ts index 881c696094..4ab7ecef87 100644 --- a/app/products/calls/components/calls_custom_message/index.ts +++ b/app/products/calls/components/calls_custom_message/index.ts @@ -7,6 +7,7 @@ import {combineLatest, of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import {CallsCustomMessage} from '@calls/components/calls_custom_message/calls_custom_message'; +import {observeIsCallLimitRestricted} from '@calls/observers'; import {observeCurrentCall} from '@calls/state'; import {Preferences} from '@constants'; import DatabaseManager from '@database/manager'; @@ -18,7 +19,12 @@ import {observeCurrentUser, observeTeammateNameDisplay, observeUser} from '@quer import type {WithDatabaseArgs} from '@typings/database/database'; import type PostModel from '@typings/database/models/servers/post'; -const enhanced = withObservables(['post'], ({post, database}: { post: PostModel } & WithDatabaseArgs) => { +type OwnProps = { + serverUrl: string; + post: PostModel; +} + +const enhanced = withObservables(['post'], ({serverUrl, post, database}: OwnProps & WithDatabaseArgs) => { const currentUser = observeCurrentUser(database); const author = observeUser(database, post.userId); const isMilitaryTime = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).observeWithColumns(['value']).pipe( @@ -63,6 +69,7 @@ const enhanced = withObservables(['post'], ({post, database}: { post: PostModel currentCallChannelId, leaveChannelName, joinChannelName, + limitRestrictedInfo: observeIsCallLimitRestricted(serverUrl, post.channelId), }; }); diff --git a/app/products/calls/components/channel_info_start/channel_info_start_button.tsx b/app/products/calls/components/channel_info_start/channel_info_start_button.tsx index 4e40814537..9d0e453a0d 100644 --- a/app/products/calls/components/channel_info_start/channel_info_start_button.tsx +++ b/app/products/calls/components/channel_info_start/channel_info_start_button.tsx @@ -5,11 +5,14 @@ import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; import {leaveCall} from '@calls/actions'; +import {showLimitRestrictedAlert} from '@calls/alerts'; import leaveAndJoinWithAlert from '@calls/components/leave_and_join_alert'; import {useTryCallsFunction} from '@calls/hooks'; import OptionBox from '@components/option_box'; import {preventDoubleTap} from '@utils/tap'; +import type {LimitRestrictedInfo} from '@calls/observers'; + export interface Props { serverUrl: string; displayName: string; @@ -19,6 +22,7 @@ export interface Props { alreadyInCall: boolean; currentCallChannelName: string; dismissChannelInfo: () => void; + limitRestrictedInfo: LimitRestrictedInfo; } const ChannelInfoStartButton = ({ @@ -30,18 +34,23 @@ const ChannelInfoStartButton = ({ alreadyInCall, currentCallChannelName, dismissChannelInfo, + limitRestrictedInfo, }: Props) => { const intl = useIntl(); + const isLimitRestricted = limitRestrictedInfo.limitRestricted; const toggleJoinLeave = useCallback(() => { if (alreadyInCall) { leaveCall(); + } else if (isLimitRestricted) { + showLimitRestrictedAlert(limitRestrictedInfo.maxParticipants, intl); } else { leaveAndJoinWithAlert(intl, serverUrl, channelId, currentCallChannelName, displayName, confirmToJoin, !isACallInCurrentChannel); } dismissChannelInfo(); - }, [alreadyInCall, dismissChannelInfo, intl, serverUrl, channelId, currentCallChannelName, displayName, confirmToJoin, isACallInCurrentChannel]); + }, [isLimitRestricted, alreadyInCall, dismissChannelInfo, intl, serverUrl, channelId, currentCallChannelName, displayName, confirmToJoin, isACallInCurrentChannel]); + const [tryJoin, msgPostfix] = useTryCallsFunction(toggleJoinLeave); const joinText = intl.formatMessage({id: 'mobile.calls_join_call', defaultMessage: 'Join call'}); diff --git a/app/products/calls/components/channel_info_start/index.ts b/app/products/calls/components/channel_info_start/index.ts index cb0c142f66..79e8faf83a 100644 --- a/app/products/calls/components/channel_info_start/index.ts +++ b/app/products/calls/components/channel_info_start/index.ts @@ -7,6 +7,7 @@ import {combineLatest, of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import ChannelInfoStartButton from '@calls/components/channel_info_start/channel_info_start_button'; +import {observeIsCallLimitRestricted} from '@calls/observers'; import {observeChannelsWithCalls, observeCurrentCall} from '@calls/state'; import DatabaseManager from '@database/manager'; import {observeChannel} from '@queries/servers/channel'; @@ -52,6 +53,7 @@ const enhanced = withObservables([], ({serverUrl, channelId, database}: EnhanceP alreadyInCall, currentCall, currentCallChannelName, + limitRestrictedInfo: observeIsCallLimitRestricted(serverUrl, channelId), }; }); diff --git a/app/products/calls/components/join_call_banner/index.ts b/app/products/calls/components/join_call_banner/index.ts index 5022b10fd9..4dba91a15d 100644 --- a/app/products/calls/components/join_call_banner/index.ts +++ b/app/products/calls/components/join_call_banner/index.ts @@ -7,6 +7,7 @@ import {of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import JoinCallBanner from '@calls/components/join_call_banner/join_call_banner'; +import {observeIsCallLimitRestricted} from '@calls/observers'; import {observeCallsState, observeCurrentCall} from '@calls/state'; import {idsAreEqual} from '@calls/utils'; import {observeChannel} from '@queries/servers/channel'; @@ -60,6 +61,7 @@ const enhanced = withObservables(['serverUrl', 'channelId'], ({ inACall, currentCallChannelName, channelCallStartTime, + limitRestrictedInfo: observeIsCallLimitRestricted(serverUrl, channelId), }; }); diff --git a/app/products/calls/components/join_call_banner/join_call_banner.tsx b/app/products/calls/components/join_call_banner/join_call_banner.tsx index ae17f90346..2806f3fc09 100644 --- a/app/products/calls/components/join_call_banner/join_call_banner.tsx +++ b/app/products/calls/components/join_call_banner/join_call_banner.tsx @@ -3,8 +3,9 @@ import React from 'react'; import {useIntl} from 'react-intl'; -import {View, Text, Pressable} from 'react-native'; +import {View, Pressable} from 'react-native'; +import {showLimitRestrictedAlert} from '@calls/alerts'; import leaveAndJoinWithAlert from '@calls/components/leave_and_join_alert'; import CompassIcon from '@components/compass_icon'; import FormattedRelativeTime from '@components/formatted_relative_time'; @@ -15,6 +16,7 @@ import {JOIN_CALL_BAR_HEIGHT} from '@constants/view'; import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import type {LimitRestrictedInfo} from '@calls/observers'; import type UserModel from '@typings/database/models/servers/user'; type Props = { @@ -25,48 +27,61 @@ type Props = { participants: UserModel[]; currentCallChannelName: string; channelCallStartTime: number; + limitRestrictedInfo: LimitRestrictedInfo; } -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { - return { - container: { - flexDirection: 'row', - backgroundColor: '#3DB887', - width: '100%', - padding: 5, - justifyContent: 'center', - alignItems: 'center', - height: JOIN_CALL_BAR_HEIGHT, - }, - joinCallIcon: { - color: theme.sidebarText, - marginLeft: 10, - marginRight: 5, - }, - joinCall: { - color: theme.sidebarText, - fontWeight: 'bold', - fontSize: 16, - }, - started: { - flex: 1, - color: theme.sidebarText, - fontWeight: '400', - marginLeft: 10, - }, - avatars: { - marginRight: 5, - }, - headerText: { - color: changeOpacity(theme.centerChannelColor, 0.56), - fontSize: 12, - fontWeight: '600', - paddingHorizontal: 16, - paddingVertical: 0, - top: 16, - }, - }; -}); +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + outerContainer: { + backgroundColor: theme.centerChannelBg, + }, + innerContainer: { + flexDirection: 'row', + backgroundColor: '#3DB887', + width: '100%', + padding: 5, + justifyContent: 'center', + alignItems: 'center', + height: JOIN_CALL_BAR_HEIGHT, + }, + innerContainerRestricted: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.48), + }, + joinCallIcon: { + color: theme.sidebarText, + marginLeft: 10, + marginRight: 5, + }, + joinCall: { + color: theme.sidebarText, + fontWeight: 'bold', + fontSize: 16, + }, + started: { + flex: 1, + color: theme.sidebarText, + fontWeight: '400', + marginLeft: 10, + }, + limitReached: { + flex: 1, + display: 'flex', + textAlign: 'right', + marginRight: 10, + color: '#FFFFFFD6', + fontWeight: '400', + }, + avatars: { + marginRight: 5, + }, + headerText: { + color: changeOpacity(theme.centerChannelColor, 0.56), + fontSize: 12, + fontWeight: '600', + paddingHorizontal: 16, + paddingVertical: 0, + top: 16, + }, +})); const JoinCallBanner = ({ channelId, @@ -76,45 +91,60 @@ const JoinCallBanner = ({ inACall, currentCallChannelName, channelCallStartTime, + limitRestrictedInfo, }: Props) => { const intl = useIntl(); const theme = useTheme(); const style = getStyleSheet(theme); + const isLimitRestricted = limitRestrictedInfo.limitRestricted; const joinHandler = async () => { + if (isLimitRestricted) { + showLimitRestrictedAlert(limitRestrictedInfo.maxParticipants, intl); + return; + } leaveAndJoinWithAlert(intl, serverUrl, channelId, currentCallChannelName, displayName, inACall, false); }; return ( - - - - - + + - - - - - + {isLimitRestricted ? ( + + ) : ( + + )} + + + + + ); }; diff --git a/app/products/calls/components/leave_and_join_alert.tsx b/app/products/calls/components/leave_and_join_alert.tsx index d6a058894b..d47517a4c2 100644 --- a/app/products/calls/components/leave_and_join_alert.tsx +++ b/app/products/calls/components/leave_and_join_alert.tsx @@ -73,7 +73,7 @@ export const doJoinCall = async (serverUrl: string, channelId: string, intl: Int const res = await joinCall(serverUrl, channelId); if (res.error) { - const seeLogs = formatMessage({id: 'mobile.calls_see_logs', defaultMessage: 'see server logs'}); + const seeLogs = formatMessage({id: 'mobile.calls_see_logs', defaultMessage: 'See server logs'}); errorAlert(res.error?.toString() || seeLogs, intl); } }; diff --git a/app/products/calls/hooks.ts b/app/products/calls/hooks.ts index 168c83e512..99f6e97490 100644 --- a/app/products/calls/hooks.ts +++ b/app/products/calls/hooks.ts @@ -53,7 +53,7 @@ export const useTryCallsFunction = (fn: () => void) => { }); const notAvailable = intl.formatMessage({ id: 'mobile.calls_not_available_option', - defaultMessage: '(Not Available)', + defaultMessage: '(Not available)', }); Alert.alert( diff --git a/app/products/calls/observers/index.ts b/app/products/calls/observers/index.ts new file mode 100644 index 0000000000..f585c8a5ea --- /dev/null +++ b/app/products/calls/observers/index.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} from '@nozbe/watermelondb'; +import {distinctUntilChanged, switchMap, combineLatest, Observable, of as of$} from 'rxjs'; + +import {observeCallsConfig, observeCallsState} from '@calls/state'; +import {General, License} from '@constants'; +import {observeChannel} from '@queries/servers/channel'; +import {observeLicense} from '@queries/servers/system'; + +export const observeIsCallsFeatureRestricted = (database: Database, serverUrl: string, channelId: string) => { + const isCloud = observeLicense(database).pipe( + switchMap((l) => of$(l?.Cloud === 'true')), + distinctUntilChanged(), + ); + const skuShortName = observeCallsConfig(serverUrl).pipe( + switchMap((c) => of$(c.sku_short_name)), + distinctUntilChanged(), + ); + const isDMChannel = observeChannel(database, channelId).pipe( + switchMap((c) => of$(c?.type === General.DM_CHANNEL)), + distinctUntilChanged(), + ); + return combineLatest([isCloud, skuShortName, isDMChannel]).pipe( + switchMap(([cloud, sku, dm]) => of$(cloud && sku === License.SKU_SHORT_NAME.Starter && !dm)), // are you restricted from making a call because of your subscription? + distinctUntilChanged(), + ) as Observable; +}; + +export type LimitRestrictedInfo = { + limitRestricted: boolean; + maxParticipants: number; +} + +export const observeIsCallLimitRestricted = (serverUrl: string, channelId: string) => { + const maxParticipants = observeCallsConfig(serverUrl).pipe( + switchMap((c) => of$(c.MaxCallParticipants)), + distinctUntilChanged(), + ); + const callNumOfParticipants = observeCallsState(serverUrl).pipe( + switchMap((cs) => of$(Object.keys(cs.calls[channelId]?.participants || {}).length)), + distinctUntilChanged(), + ); + return combineLatest([maxParticipants, callNumOfParticipants]).pipe( + switchMap(([max, numParticipants]) => of$({ + limitRestricted: max !== 0 && numParticipants >= max, + maxParticipants: max, + })), + distinctUntilChanged((prev, curr) => + prev.limitRestricted === curr.limitRestricted && prev.maxParticipants === curr.maxParticipants), + ) as Observable; +}; diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index a6cfb1190d..30721001b2 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -31,6 +31,7 @@ import { setConfig, setPluginEnabled, } from '@calls/state/actions'; +import {License} from '@constants'; import {CallsState, CurrentCall, DefaultCallsConfig, DefaultCallsState} from '../types/calls'; @@ -632,6 +633,8 @@ describe('useCallsState', () => { DefaultEnabled: true, NeedsTURNCredentials: false, last_retrieved_at: 123, + sku_short_name: License.SKU_SHORT_NAME.Professional, + MaxCallParticipants: 8, }; // setup diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index f35bd23bef..28f0fb1550 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -11,7 +11,7 @@ import { setChannelsWithCalls, setCurrentCall, } from '@calls/state'; -import {Call, ChannelsWithCalls, ServerCallsConfig} from '@calls/types/calls'; +import {Call, CallsConfig, ChannelsWithCalls} from '@calls/types/calls'; export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary, enabled: Dictionary) => { const channelsWithCalls = Object.keys(calls).reduce( @@ -303,7 +303,7 @@ export const setSpeakerPhone = (speakerphoneOn: boolean) => { } }; -export const setConfig = (serverUrl: string, config: ServerCallsConfig) => { +export const setConfig = (serverUrl: string, config: Partial) => { const callsConfig = getCallsConfig(serverUrl); setCallsConfig(serverUrl, {...callsConfig, ...config}); }; diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index b8d738d295..43f76dc5a0 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -97,6 +97,8 @@ export type ServerCallsConfig = { AllowEnableCalls: boolean; DefaultEnabled: boolean; NeedsTURNCredentials: boolean; + sku_short_name: string; + MaxCallParticipants: number; } export type CallsConfig = ServerCallsConfig & { @@ -112,6 +114,8 @@ export const DefaultCallsConfig = { DefaultEnabled: false, NeedsTURNCredentials: false, last_retrieved_at: 0, + sku_short_name: '', + MaxCallParticipants: 0, } as CallsConfig; export type ICEServersConfigs = Array; diff --git a/app/products/calls/utils.test.ts b/app/products/calls/utils.test.ts index af0dae32aa..7a74ea1124 100644 --- a/app/products/calls/utils.test.ts +++ b/app/products/calls/utils.test.ts @@ -3,6 +3,8 @@ import assert from 'assert'; +import {License} from '@constants'; + import {getICEServersConfigs} from './utils'; describe('getICEServersConfigs', () => { @@ -13,6 +15,8 @@ describe('getICEServersConfigs', () => { DefaultEnabled: true, NeedsTURNCredentials: false, last_retrieved_at: 0, + sku_short_name: License.SKU_SHORT_NAME.Professional, + MaxCallParticipants: 8, }; const iceConfigs = getICEServersConfigs(config); @@ -37,6 +41,8 @@ describe('getICEServersConfigs', () => { DefaultEnabled: true, NeedsTURNCredentials: false, last_retrieved_at: 0, + sku_short_name: License.SKU_SHORT_NAME.Professional, + MaxCallParticipants: 8, }; const iceConfigs = getICEServersConfigs(config); @@ -65,6 +71,8 @@ describe('getICEServersConfigs', () => { DefaultEnabled: true, NeedsTURNCredentials: false, last_retrieved_at: 0, + sku_short_name: License.SKU_SHORT_NAME.Professional, + MaxCallParticipants: 8, }; const iceConfigs = getICEServersConfigs(config); diff --git a/app/queries/servers/user.ts b/app/queries/servers/user.ts index c478c615a0..784f516d39 100644 --- a/app/queries/servers/user.ts +++ b/app/queries/servers/user.ts @@ -2,12 +2,14 @@ // See LICENSE.txt for license information. import {Database, Q} from '@nozbe/watermelondb'; -import {combineLatest, of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; +import {combineLatest, Observable, of as of$} from 'rxjs'; +import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import {Preferences} from '@constants'; import {MM_TABLES} from '@constants/database'; import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; +import {observeMyChannel} from '@queries/servers/channel'; +import {isChannelAdmin} from '@utils/user'; import {queryPreferencesByCategoryAndName} from './preference'; import {observeConfig, observeCurrentUserId, observeLicense, getCurrentUserId, getConfig, getLicense} from './system'; @@ -110,9 +112,17 @@ export const observeUserIsTeamAdmin = (database: Database, userId: string, teamI export const observeUserIsChannelAdmin = (database: Database, userId: string, channelId: string) => { const id = `${channelId}-${userId}`; - return database.get(CHANNEL_MEMBERSHIP).query( + const myChannelRoles = observeMyChannel(database, channelId).pipe( + switchMap((mc) => of$(mc?.roles || '')), + distinctUntilChanged(), + ); + const channelSchemeAdmin = database.get(CHANNEL_MEMBERSHIP).query( Q.where('id', Q.eq(id)), ).observe().pipe( - switchMap((tm) => of$(tm.length ? tm[0].schemeAdmin : false)), + switchMap((cm) => of$(cm.length ? cm[0].schemeAdmin : false)), + distinctUntilChanged(), ); + return combineLatest([myChannelRoles, channelSchemeAdmin]).pipe( + switchMap(([mcr, csa]) => of$(isChannelAdmin(mcr) || csa)), + ) as Observable; }; diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index 354fbfbdb9..2029a6d1bb 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -144,9 +144,10 @@ const Channel = ({ onLayout={onLayout} > {shouldRender && <> diff --git a/app/screens/channel/header/header.tsx b/app/screens/channel/header/header.tsx index d683c1132a..2f0dc648ef 100644 --- a/app/screens/channel/header/header.tsx +++ b/app/screens/channel/header/header.tsx @@ -38,7 +38,8 @@ type ChannelProps = { memberCount?: number; searchTerm: string; teamId: string; - callsEnabled: boolean; + callsEnabledInChannel: boolean; + callsFeatureRestricted: boolean; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ @@ -66,7 +67,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ const ChannelHeader = ({ channelId, channelType, componentId, customStatus, displayName, isCustomStatusExpired, isOwnDirectMessage, memberCount, - searchTerm, teamId, callsEnabled, + searchTerm, teamId, callsEnabledInChannel, callsFeatureRestricted, }: ChannelProps) => { const intl = useIntl(); const isTablet = useIsTablet(); @@ -74,6 +75,7 @@ const ChannelHeader = ({ const styles = getStyleSheet(theme); const defaultHeight = useDefaultHeaderHeight(); const insets = useSafeAreaInsets(); + const callsAvailable = callsEnabledInChannel && !callsFeatureRestricted; const isDMorGM = isTypeDMorGM(channelType); const contextStyle = useMemo(() => ({ @@ -129,13 +131,13 @@ const ChannelHeader = ({ } // When calls is enabled, we need space to move the "Copy Link" from a button to an option - const height = QUICK_OPTIONS_HEIGHT + (callsEnabled && !isDMorGM ? ITEM_HEIGHT : 0); + const height = QUICK_OPTIONS_HEIGHT + (callsAvailable && !isDMorGM ? ITEM_HEIGHT : 0); const renderContent = () => { return ( ); @@ -148,7 +150,7 @@ const ChannelHeader = ({ theme, closeButtonId: 'close-channel-quick-actions', }); - }, [channelId, isDMorGM, isTablet, onTitlePress, theme, callsEnabled]); + }, [channelId, isDMorGM, isTablet, onTitlePress, theme, callsAvailable]); const rightButtons: HeaderRightButton[] = useMemo(() => ([ diff --git a/app/screens/channel/header/index.ts b/app/screens/channel/header/index.ts index a7a9d8efe7..7f03c32cbe 100644 --- a/app/screens/channel/header/index.ts +++ b/app/screens/channel/header/index.ts @@ -6,17 +6,27 @@ import withObservables from '@nozbe/with-observables'; import {of as of$} from 'rxjs'; import {combineLatestWith, switchMap} from 'rxjs/operators'; +import {observeIsCallsFeatureRestricted} from '@calls/observers'; import {General} from '@constants'; import {observeChannel, observeChannelInfo} from '@queries/servers/channel'; import {observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system'; import {observeUser} from '@queries/servers/user'; -import {getUserCustomStatus, getUserIdFromChannelName, isCustomStatusExpired as checkCustomStatusIsExpired} from '@utils/user'; +import { + getUserCustomStatus, + getUserIdFromChannelName, + isCustomStatusExpired as checkCustomStatusIsExpired, +} from '@utils/user'; import ChannelHeader from './header'; import type {WithDatabaseArgs} from '@typings/database/database'; -const enhanced = withObservables(['channelId'], ({channelId, database}: WithDatabaseArgs & {channelId: string}) => { +type OwnProps = { + serverUrl: string; + channelId: string; +}; + +const enhanced = withObservables(['channelId'], ({serverUrl, channelId, database}: OwnProps & WithDatabaseArgs) => { const currentUserId = observeCurrentUserId(database); const teamId = observeCurrentTeamId(database); @@ -77,6 +87,7 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: WithData memberCount, searchTerm, teamId, + callsFeatureRestricted: observeIsCallsFeatureRestricted(database, serverUrl, channelId), }; }); diff --git a/app/screens/channel_info/channel_info.tsx b/app/screens/channel_info/channel_info.tsx index 534778ea4f..2d44f1bb91 100644 --- a/app/screens/channel_info/channel_info.tsx +++ b/app/screens/channel_info/channel_info.tsx @@ -24,6 +24,7 @@ type Props = { type?: ChannelType; canEnableDisableCalls: boolean; isCallsEnabledInChannel: boolean; + isCallsFeatureRestricted: boolean; } const edges: Edge[] = ['bottom', 'left', 'right']; @@ -50,9 +51,11 @@ const ChannelInfo = ({ type, canEnableDisableCalls, isCallsEnabledInChannel, + isCallsFeatureRestricted, }: Props) => { const theme = useTheme(); const styles = getStyleSheet(theme); + const callsAvailable = isCallsEnabledInChannel && !isCallsFeatureRestricted; const onPressed = useCallback(() => { dismissModal({componentId}); @@ -80,7 +83,7 @@ const ChannelInfo = ({ channelId={channelId} inModal={true} dismissChannelInfo={onPressed} - callsEnabled={isCallsEnabledInChannel} + callsEnabled={callsAvailable} testID='channel_info.channel_actions' /> @@ -88,10 +91,10 @@ const ChannelInfo = ({ - {canEnableDisableCalls && + {canEnableDisableCalls && !isCallsFeatureRestricted && <> { }), distinctUntilChanged(), ); + const isCallsFeatureRestricted = channelId.pipe( + switchMap((id) => observeIsCallsFeatureRestricted(database, serverUrl, id)), + ); return { type, canEnableDisableCalls, isCallsEnabledInChannel, + isCallsFeatureRestricted, }; }); diff --git a/app/utils/user/index.ts b/app/utils/user/index.ts index c53e427e8d..366b84a9cf 100644 --- a/app/utils/user/index.ts +++ b/app/utils/user/index.ts @@ -91,6 +91,10 @@ export function isSystemAdmin(roles: string): boolean { return isRoleInRoles(roles, Permissions.SYSTEM_ADMIN_ROLE); } +export function isChannelAdmin(roles: string): boolean { + return isRoleInRoles(roles, Permissions.CHANNEL_ADMIN_ROLE); +} + export const getUsersByUsername = (users: UserModel[]) => { const usersByUsername: Dictionary = {}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 2251c57a4e..526ab10c37 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -374,6 +374,8 @@ "mobile.calls_lasted": "Lasted {duration}", "mobile.calls_leave": "Leave", "mobile.calls_leave_call": "Leave call", + "mobile.calls_limit_msg": "The maximum number of participants per call is {maxParticipants}. Contact your System Admin to increase the limit.", + "mobile.calls_limit_reached": "Participant limit reached", "mobile.calls_lower_hand": "Lower hand", "mobile.calls_more": "More", "mobile.calls_mute": "Mute", @@ -381,11 +383,12 @@ "mobile.calls_name_started_call": "{name} started a call", "mobile.calls_noone_talking": "No one is talking", "mobile.calls_not_available_msg": "Please contact your System Admin to enable the feature.", - "mobile.calls_not_available_option": "(Not Available)", + "mobile.calls_not_available_option": "(Not available)", "mobile.calls_not_available_title": "Calls is not enabled", "mobile.calls_ok": "OK", + "mobile.calls_participant_limit_title": "Participant limit reached", "mobile.calls_raise_hand": "Raise hand", - "mobile.calls_see_logs": "see server logs", + "mobile.calls_see_logs": "See server logs", "mobile.calls_speaker": "Speaker", "mobile.calls_start_call": "Start Call", "mobile.calls_unmute": "Unmute",