diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 2f5beb170..af36ba38b 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -9,6 +9,7 @@ import {DeviceEventEmitter} from 'react-native'; import {addChannelToDefaultCategory, storeCategories} from '@actions/local/category'; import {removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel'; import {switchToGlobalThreads} from '@actions/local/thread'; +import {loadCallForChannel} from '@calls/actions/calls'; import {Events, General, Preferences, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {privateChannelJoinPrompt} from '@helpers/api/channel'; @@ -598,6 +599,7 @@ export async function joinChannel(serverUrl: string, teamId: string, channelId?: } if (channelId || channel?.id) { + loadCallForChannel(serverUrl, channelId || channel!.id); EphemeralStore.removeJoiningChannel(channelId || channel!.id); } return {channel, member}; diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 55b70813f..c363eabef 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -104,7 +104,10 @@ export const loadCalls = async (serverUrl: string, userId: string) => { if (channel.call) { callsResults[channel.channel_id] = createCallAndAddToIds(channel.channel_id, channel.call, ids); } - enabledChannels[channel.channel_id] = channel.enabled; + + if (typeof channel.enabled !== 'undefined') { + enabledChannels[channel.channel_id] = channel.enabled; + } } // Batch load user models async because we'll need them later diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts index 36db5e70e..86395da6b 100644 --- a/app/products/calls/alerts.ts +++ b/app/products/calls/alerts.ts @@ -5,7 +5,7 @@ import {Alert} from 'react-native'; import {Navigation} from 'react-native-navigation'; import {hasMicrophonePermission, joinCall, leaveCall, unmuteMyself} from '@calls/actions'; -import {setMicPermissionsGranted} from '@calls/state'; +import {getCallsConfig, getCallsState, setMicPermissionsGranted} from '@calls/state'; import {errorAlert} from '@calls/utils'; import {Screens} from '@constants'; import DatabaseManager from '@database/manager'; @@ -13,6 +13,7 @@ import {getCurrentUser} from '@queries/servers/user'; import {dismissAllModals, dismissAllModalsAndPopToScreen} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; import {logError} from '@utils/log'; +import {isSystemAdmin} from '@utils/user'; import type {IntlShape} from 'react-intl'; @@ -91,17 +92,17 @@ export const leaveAndJoinWithAlert = ( id: 'mobile.leave_and_join_confirmation', defaultMessage: 'Leave & Join', }), - onPress: () => doJoinCall(serverUrl, channelId, isDMorGM, intl), + onPress: () => doJoinCall(serverUrl, channelId, isDMorGM, newCall, intl), style: 'cancel', }, ], ); } else { - doJoinCall(serverUrl, channelId, isDMorGM, intl); + doJoinCall(serverUrl, channelId, isDMorGM, newCall, intl); } }; -const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolean, intl: IntlShape) => { +const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolean, newCall: boolean, intl: IntlShape) => { const {formatMessage} = intl; let user; @@ -113,6 +114,30 @@ const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolea // This shouldn't happen, so don't bother localizing and displaying an alert. return; } + + if (newCall) { + const enabled = getCallsState(serverUrl).enabled[channelId]; + const {DefaultEnabled} = getCallsConfig(serverUrl); + const isAdmin = isSystemAdmin(user.roles); + + // if explicitly disabled, we wouldn't get to this point. + // if pre-GA calls: + // if enabled is false, then this channel was returned as enabled=false from the server (it was either + // explicitly disabled, or DefaultEnabled=false), and the StartCall button would not be shown + // if enabled is true, then this channel was return as enabled=true from the server (it was either + // explicitly enabled, or DefaultEnabled=true), everyone can start + // if GA calls: + // if explicitly enabled, everyone can start a call + // if !explicitly enabled and defaultEnabled, everyone can start + // if !explicitly enabled and !defaultEnabled, system admins can start, regular users get alert + // Note: the below is a 'badly' coded if. But it's clear, which trumps. + if (enabled || (!enabled && DefaultEnabled) || (!enabled && !DefaultEnabled && isAdmin)) { + // continue through and start the call + } else { + contactAdminAlert(intl); + return; + } + } } catch (error) { logError('failed to getServerDatabaseAndOperator in doJoinCall', error); return; @@ -139,6 +164,25 @@ const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolea } }; +const contactAdminAlert = ({formatMessage}: IntlShape) => { + Alert.alert( + formatMessage({ + id: 'mobile.calls_request_title', + defaultMessage: 'Calls is not currently enabled', + }), + formatMessage({ + id: 'mobile.calls_request_message', + defaultMessage: 'Calls are currently running in test mode and only system admins can start them. Reach out directly to your system admin for assistance', + }), + [{ + text: formatMessage({ + id: 'mobile.calls_okay', + defaultMessage: 'Okay', + }), + }], + ); +}; + export const recordingAlert = (isHost: boolean, intl: IntlShape) => { if (recordingAlertLock) { return; diff --git a/app/products/calls/observers/index.ts b/app/products/calls/observers/index.ts index f585c8a5e..e27c6a7d6 100644 --- a/app/products/calls/observers/index.ts +++ b/app/products/calls/observers/index.ts @@ -1,38 +1,41 @@ // 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'; +import {observeConfigValue} from '@queries/servers/system'; +import {isMinimumServerVersion} from '@utils/helpers'; -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; -}; +import type {Database} from '@nozbe/watermelondb'; export type LimitRestrictedInfo = { limitRestricted: boolean; maxParticipants: number; } +export const observeIsCallsEnabledInChannel = (database: Database, serverUrl: string, channelId: Observable) => { + const callsDefaultEnabled = observeCallsConfig(serverUrl).pipe( + switchMap((config) => of$(config.DefaultEnabled)), + distinctUntilChanged(), + ); + const callsStateEnabledDict = observeCallsState(serverUrl).pipe( + switchMap((state) => of$(state.enabled)), + distinctUntilChanged(), // Did the enabled object ref change? If so, a channel's enabled state has changed. + ); + const callsGAServer = observeConfigValue(database, 'Version').pipe( + switchMap((v) => of$(isMinimumServerVersion(v || '', 7, 6))), + ); + return combineLatest([channelId, callsStateEnabledDict, callsDefaultEnabled, callsGAServer]).pipe( + switchMap(([id, enabled, defaultEnabled, gaServer]) => { + const explicitlyEnabled = enabled.hasOwnProperty(id as string) && enabled[id]; + const explicitlyDisabled = enabled.hasOwnProperty(id as string) && !enabled[id]; + return of$(explicitlyEnabled || (!explicitlyDisabled && defaultEnabled) || (!explicitlyDisabled && gaServer)); + }), + distinctUntilChanged(), + ) as Observable; +}; + export const observeIsCallLimitRestricted = (serverUrl: string, channelId: string) => { const maxParticipants = observeCallsConfig(serverUrl).pipe( switchMap((c) => of$(c.MaxCallParticipants)), diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index 34c6d3cc8..073ec7434 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -812,6 +812,7 @@ describe('useCallsState', () => { sku_short_name: License.SKU_SHORT_NAME.Professional, MaxCallParticipants: 8, EnableRecordings: true, + bot_user_id: '', }; // setup diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 9d0d1f10c..9a5be5773 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -52,9 +52,12 @@ export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary< setCurrentCall(nextCall); }; -export const setCallForChannel = (serverUrl: string, channelId: string, enabled: boolean, call?: Call) => { +export const setCallForChannel = (serverUrl: string, channelId: string, enabled?: boolean, call?: Call) => { const callsState = getCallsState(serverUrl); - const nextEnabled = {...callsState.enabled, [channelId]: enabled}; + let nextEnabled = callsState.enabled; + if (typeof enabled !== 'undefined') { + nextEnabled = {...callsState.enabled, [channelId]: enabled}; + } const nextCalls = {...callsState.calls}; if (call) { diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index fd9c4d144..5dffc2b6b 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -84,7 +84,7 @@ export type ChannelsWithCalls = Dictionary; export type ServerChannelState = { channel_id: string; - enabled: boolean; + enabled?: boolean; call?: ServerCallState; } diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index 05c8c12dc..d76022096 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -142,7 +142,6 @@ const Channel = ({ onLayout={onLayout} > ({ @@ -67,14 +66,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ const ChannelHeader = ({ channelId, channelType, componentId, customStatus, displayName, isCustomStatusEnabled, isCustomStatusExpired, isOwnDirectMessage, memberCount, - searchTerm, teamId, callsEnabledInChannel, callsFeatureRestricted, + searchTerm, teamId, callsEnabledInChannel, }: ChannelProps) => { const intl = useIntl(); const isTablet = useIsTablet(); const theme = useTheme(); const styles = getStyleSheet(theme); const defaultHeight = useDefaultHeaderHeight(); - const callsAvailable = callsEnabledInChannel && !callsFeatureRestricted; + + // NOTE: callsEnabledInChannel will be true/false (not undefined) based on explicit state + the DefaultEnabled system setting + // which ultimately comes from channel/index.tsx, and observeIsCallsEnabledInChannel + const callsAvailable = callsEnabledInChannel; const isDMorGM = isTypeDMorGM(channelType); const contextStyle = useMemo(() => ({ diff --git a/app/screens/channel/header/index.ts b/app/screens/channel/header/index.ts index c7da84184..b6b10b00c 100644 --- a/app/screens/channel/header/index.ts +++ b/app/screens/channel/header/index.ts @@ -6,7 +6,6 @@ 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 {observeConfigBooleanValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system'; @@ -22,11 +21,10 @@ import ChannelHeader from './header'; import type {WithDatabaseArgs} from '@typings/database/database'; type OwnProps = { - serverUrl: string; channelId: string; }; -const enhanced = withObservables(['channelId'], ({serverUrl, channelId, database}: OwnProps & WithDatabaseArgs) => { +const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps & WithDatabaseArgs) => { const currentUserId = observeCurrentUserId(database); const teamId = observeCurrentTeamId(database); @@ -90,7 +88,6 @@ const enhanced = withObservables(['channelId'], ({serverUrl, channelId, database memberCount, searchTerm, teamId, - callsFeatureRestricted: observeIsCallsFeatureRestricted(database, serverUrl, channelId), }; }); diff --git a/app/screens/channel/index.tsx b/app/screens/channel/index.tsx index f911ca0e4..a936030b4 100644 --- a/app/screens/channel/index.tsx +++ b/app/screens/channel/index.tsx @@ -6,7 +6,8 @@ import withObservables from '@nozbe/with-observables'; import {combineLatest, of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; -import {observeCallsConfig, observeCallsState, observeChannelsWithCalls, observeCurrentCall} from '@calls/state'; +import {observeIsCallsEnabledInChannel} from '@calls/observers'; +import {observeChannelsWithCalls, observeCurrentCall} from '@calls/state'; import {withServerUrl} from '@context/server'; import {observeCurrentChannelId} from '@queries/servers/system'; @@ -37,22 +38,7 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => { switchMap(([id, ccId]) => of$(id === ccId)), distinctUntilChanged(), ); - const callsStateEnabledDict = observeCallsState(serverUrl).pipe( - switchMap((state) => of$(state.enabled)), - distinctUntilChanged(), // Did the enabled object ref change? If so, a channel's enabled state has changed. - ); - const callsDefaultEnabled = observeCallsConfig(serverUrl).pipe( - switchMap((config) => of$(config.DefaultEnabled)), - distinctUntilChanged(), - ); - const isCallsEnabledInChannel = combineLatest([channelId, callsStateEnabledDict, callsDefaultEnabled]).pipe( - switchMap(([id, enabled, defaultEnabled]) => { - const explicitlyEnabled = enabled.hasOwnProperty(id as string) && enabled[id]; - const explicitlyDisabled = enabled.hasOwnProperty(id as string) && !enabled[id]; - return of$(explicitlyEnabled || (!explicitlyDisabled && defaultEnabled)); - }), - distinctUntilChanged(), - ); + const isCallsEnabledInChannel = observeIsCallsEnabledInChannel(database, serverUrl, channelId); return { channelId, diff --git a/app/screens/channel_info/channel_info.tsx b/app/screens/channel_info/channel_info.tsx index cf44f619c..32f42ad4f 100644 --- a/app/screens/channel_info/channel_info.tsx +++ b/app/screens/channel_info/channel_info.tsx @@ -26,7 +26,6 @@ type Props = { type?: ChannelType; canEnableDisableCalls: boolean; isCallsEnabledInChannel: boolean; - isCallsFeatureRestricted: boolean; } const edges: Edge[] = ['bottom', 'left', 'right']; @@ -53,12 +52,14 @@ const ChannelInfo = ({ type, canEnableDisableCalls, isCallsEnabledInChannel, - isCallsFeatureRestricted, }: Props) => { const theme = useTheme(); const serverUrl = useServerUrl(); const styles = getStyleSheet(theme); - const callsAvailable = isCallsEnabledInChannel && !isCallsFeatureRestricted; + + // NOTE: isCallsEnabledInChannel will be true/false (not undefined) based on explicit state + the DefaultEnabled system setting + // which comes from observeIsCallsEnabledInChannel + const callsAvailable = isCallsEnabledInChannel; const onPressed = useCallback(() => { return dismissModal({componentId}); @@ -97,7 +98,7 @@ const ChannelInfo = ({ callsEnabled={callsAvailable} /> - {canEnableDisableCalls && !isCallsFeatureRestricted && + {canEnableDisableCalls && <> { const channel = observeCurrentChannel(database); const type = channel.pipe(switchMap((c) => of$(c?.type))); const channelId = channel.pipe(switchMap((c) => of$(c?.id || ''))); + const teamId = channel.pipe(switchMap((c) => (c?.teamId ? of$(c.teamId) : observeCurrentTeamId(database)))); + const userId = observeCurrentUserId(database); + const isTeamAdmin = combineLatest([teamId, userId]).pipe( + switchMap(([tId, uId]) => observeUserIsTeamAdmin(database, uId, tId)), + ); + // callsDefaultEnabled means "live mode" post 7.6 + const callsDefaultEnabled = observeCallsConfig(serverUrl).pipe( + switchMap((config) => of$(config.DefaultEnabled)), + distinctUntilChanged(), + ); const allowEnableCalls = observeCallsConfig(serverUrl).pipe( switchMap((config) => of$(config.AllowEnableCalls)), distinctUntilChanged(), @@ -37,48 +53,55 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => { switchMap((roles) => of$(isSystemAdmin(roles || ''))), distinctUntilChanged(), ); - const channelAdmin = combineLatest([observeCurrentUserId(database), channelId]).pipe( - switchMap(([userId, chId]) => observeUserIsChannelAdmin(database, userId, chId)), + const channelAdmin = combineLatest([userId, channelId]).pipe( + switchMap(([uId, chId]) => observeUserIsChannelAdmin(database, uId, chId)), distinctUntilChanged(), ); - const canEnableDisableCalls = combineLatest([type, allowEnableCalls, systemAdmin, channelAdmin]).pipe( - switchMap(([t, allow, sysAdmin, chAdmin]) => { - const isDirectMessage = t === General.DM_CHANNEL; - const isGroupMessage = t === General.GM_CHANNEL; + const callsGAServer = observeConfigValue(database, 'Version').pipe( + switchMap((v) => of$(isMinimumServerVersion(v || '', 7, 6))), + ); + const dmOrGM = type.pipe(switchMap((t) => of$(isTypeDMorGM(t)))); + const canEnableDisableCalls = combineLatest([callsDefaultEnabled, allowEnableCalls, systemAdmin, channelAdmin, callsGAServer, dmOrGM, isTeamAdmin]).pipe( + switchMap(([liveMode, allow, sysAdmin, chAdmin, gaServer, dmGM, tAdmin]) => { + // if GA 7.6: + // allow (will always be true) and !liveMode = system admins can enable/disable + // allow (will always be true) and liveMode = channel, team, system admins, DM/GM participants can enable/disable + // if pre GA 7.6: + // allow and !liveMode = channel, system admins, DM/GM participants can enable/disable + // allow and liveMode = channel, system admins, DM/GM participants can enable/disable + // !allow and !liveMode = system admins can enable/disable -- can combine with below + // !allow and liveMode = system admins can enable/disable -- can combine with above + // Note: There are ways to 'simplify' the conditions below. Here we're preferring clarity. - const isAdmin = sysAdmin || chAdmin; - let temp = Boolean(sysAdmin); - if (allow) { - temp = Boolean(isDirectMessage || isGroupMessage || isAdmin); + if (gaServer) { + if (allow && !liveMode) { + return of$(Boolean(sysAdmin)); + } + if (allow && liveMode) { + return of$(Boolean(chAdmin || tAdmin || sysAdmin || dmGM)); + } + return of$(false); } - return of$(temp); + + // now we're pre GA 7.6 + if (allow && liveMode) { + return of$(Boolean(chAdmin || sysAdmin || dmGM)); + } + if (allow && !liveMode) { + return of$(Boolean(sysAdmin || chAdmin || dmGM)); + } + if (!allow) { + return of$(Boolean(sysAdmin)); + } + return of$(false); }), ); - const callsDefaultEnabled = observeCallsConfig(serverUrl).pipe( - switchMap((config) => of$(config.DefaultEnabled)), - distinctUntilChanged(), - ); - const callsStateEnabledDict = observeCallsState(serverUrl).pipe( - switchMap((state) => of$(state.enabled)), - distinctUntilChanged(), // Did the enabled object ref change? If so, a channel's enabled state has changed. - ); - const isCallsEnabledInChannel = combineLatest([observeCurrentChannelId(database), callsStateEnabledDict, callsDefaultEnabled]).pipe( - switchMap(([id, enabled, defaultEnabled]) => { - const explicitlyEnabled = enabled.hasOwnProperty(id as string) && enabled[id]; - const explicitlyDisabled = enabled.hasOwnProperty(id as string) && !enabled[id]; - return of$(explicitlyEnabled || (!explicitlyDisabled && defaultEnabled)); - }), - distinctUntilChanged(), - ); - const isCallsFeatureRestricted = channelId.pipe( - switchMap((id) => observeIsCallsFeatureRestricted(database, serverUrl, id)), - ); + const isCallsEnabledInChannel = observeIsCallsEnabledInChannel(database, serverUrl, observeCurrentChannelId(database)); return { type, canEnableDisableCalls, isCallsEnabledInChannel, - isCallsFeatureRestricted, }; });