MM-45747 - Cloud freemium limits & Max participants limits (#6578)

This commit is contained in:
Christopher Poile
2022-08-20 08:57:14 -04:00
committed by GitHub
parent 1692687c32
commit d30b97ba99
27 changed files with 332 additions and 98 deletions

View File

@@ -10,6 +10,14 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />

View File

@@ -277,6 +277,7 @@ const Post = ({
} else if (isCallsPost && !hasBeenDeleted) {
body = (
<CallsCustomMessage
serverUrl={serverUrl}
post={post}
/>
);

View File

@@ -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,

12
app/constants/license.ts Normal file
View File

@@ -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',
},
};

View File

@@ -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};
};

View File

@@ -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',
},
],
);
};

View File

@@ -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 (
<View style={style.messageStyle}>
<CompassIcon
@@ -179,20 +198,20 @@ export const CallsCustomMessage = ({
</View>
<TouchableOpacity
style={style.joinCallButton}
style={[style.joinCallButton, isLimitRestricted && style.joinCallButtonRestricted]}
onPress={joinHandler}
>
<CompassIcon
name='phone-outline'
size={16}
style={style.joinCallButtonIcon}
style={[style.joinCallButtonIcon, isLimitRestricted && style.joinCallButtonIconRestricted]}
/>
{
alreadyInTheCall &&
<FormattedText
id={'mobile.calls_current_call'}
defaultMessage={'Current call'}
style={style.joinCallButtonText}
style={joinTextStyle}
/>
}
{
@@ -200,7 +219,7 @@ export const CallsCustomMessage = ({
<FormattedText
id={'mobile.calls_join_call'}
defaultMessage={'Join call'}
style={style.joinCallButtonText}
style={joinTextStyle}
/>
}
</TouchableOpacity>

View File

@@ -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),
};
});

View File

@@ -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'});

View File

@@ -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),
};
});

View File

@@ -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),
};
});

View File

@@ -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 (
<Pressable
style={style.container}
onPress={joinHandler}
>
<CompassIcon
name='phone-in-talk'
size={16}
style={style.joinCallIcon}
/>
<FormattedText
id={'mobile.calls_join_call'}
defaultMessage={'Join call'}
style={style.joinCall}
/>
<Text style={style.started}>
<FormattedRelativeTime
value={channelCallStartTime}
updateIntervalInSeconds={1}
<View style={style.outerContainer}>
<Pressable
style={[style.innerContainer, isLimitRestricted && style.innerContainerRestricted]}
onPress={joinHandler}
>
<CompassIcon
name='phone-in-talk'
size={16}
style={style.joinCallIcon}
/>
</Text>
<View style={style.avatars}>
<UserAvatarsStack
channelId={channelId}
location={Screens.CHANNEL}
users={participants}
breakAt={1}
<FormattedText
id={'mobile.calls_join_call'}
defaultMessage={'Join call'}
style={style.joinCall}
/>
</View>
</Pressable>
{isLimitRestricted ? (
<FormattedText
id={'mobile.calls_limit_reached'}
defaultMessage={'Participant limit reached'}
style={style.limitReached}
/>
) : (
<FormattedRelativeTime
value={channelCallStartTime}
updateIntervalInSeconds={1}
style={style.started}
/>
)}
<View style={style.avatars}>
<UserAvatarsStack
channelId={channelId}
location={Screens.CHANNEL}
users={participants}
breakAt={1}
/>
</View>
</Pressable>
</View>
);
};

View File

@@ -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);
}
};

View File

@@ -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(

View File

@@ -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<boolean>;
};
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<LimitRestrictedInfo>;
};

View File

@@ -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

View File

@@ -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<Call>, enabled: Dictionary<boolean>) => {
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<CallsConfig>) => {
const callsConfig = getCallsConfig(serverUrl);
setCallsConfig(serverUrl, {...callsConfig, ...config});
};

View File

@@ -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<ConfigurationParamWithUrls | ConfigurationParamWithUrl>;

View File

@@ -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);

View File

@@ -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<ChannelMembershipModel>(CHANNEL_MEMBERSHIP).query(
const myChannelRoles = observeMyChannel(database, channelId).pipe(
switchMap((mc) => of$(mc?.roles || '')),
distinctUntilChanged(),
);
const channelSchemeAdmin = database.get<ChannelMembershipModel>(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<boolean>;
};

View File

@@ -144,9 +144,10 @@ const Channel = ({
onLayout={onLayout}
>
<ChannelHeader
serverUrl={serverUrl}
channelId={channelId}
componentId={componentId}
callsEnabled={isCallsEnabledInChannel}
callsEnabledInChannel={isCallsEnabledInChannel}
/>
{shouldRender &&
<>

View File

@@ -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 (
<QuickActions
channelId={channelId}
callsEnabled={callsEnabled}
callsEnabled={callsAvailable}
isDMorGM={isDMorGM}
/>
);
@@ -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(() => ([

View File

@@ -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),
};
});

View File

@@ -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'
/>
<Extra channelId={channelId}/>
@@ -88,10 +91,10 @@ const ChannelInfo = ({
<Options
channelId={channelId}
type={type}
callsEnabled={isCallsEnabledInChannel}
callsEnabled={callsAvailable}
/>
<View style={styles.separator}/>
{canEnableDisableCalls &&
{canEnableDisableCalls && !isCallsFeatureRestricted &&
<>
<ChannelInfoEnableCalls
channelId={channelId}

View File

@@ -6,6 +6,7 @@ import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {observeIsCallsFeatureRestricted} from '@calls/observers';
import {observeCallsConfig, observeCallsState} from '@calls/state';
import {General} from '@constants';
import {withServerUrl} from '@context/server';
@@ -69,11 +70,15 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => {
}),
distinctUntilChanged(),
);
const isCallsFeatureRestricted = channelId.pipe(
switchMap((id) => observeIsCallsFeatureRestricted(database, serverUrl, id)),
);
return {
type,
canEnableDisableCalls,
isCallsEnabledInChannel,
isCallsFeatureRestricted,
};
});

View File

@@ -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<UserModel> = {};

View File

@@ -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",