MM-51228 - Calls: UX redesign & theming support (#7283)

* UX redesign

* fix call bar profile on Android

* update @mattermost/calls; use common color utils

* collapseIcon and time colour fixes (not correct yet on Onyx or Quartz)

* fix time and collapseIcon colors; fix unavailable wrapper

* center users, scroll when full; statusBar bg and styling

* better spacing; better screenshare; no reaction bar when no reactions

* remove margins from bottom; speaker/react buttonOn colour is now callsBg

* update calls-common; update raised hand button colors

* i18n

* cleaning up style sheets

* fix package-lock

* fix vertical alignment of phone icon

* add a noBorder prop to UserAvatarsStack

* typography, icon sizing, spacing changes

* add rounded header; UI improvements; refactor observables

* updating phone icon, join call margins

* join call text; color theming

* remove unneeded container

* phone-outline -> phone

* split CallsChannelState into boolean components

* use sidebar bg to generate calls bg

* fix hand icon, button texts
This commit is contained in:
Christopher Poile 2023-05-04 14:17:17 -04:00 committed by GitHub
parent 34df44ab53
commit f8f6839945
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1036 additions and 537 deletions

View file

@ -31,6 +31,7 @@ type Props = {
users: UserModel[];
breakAt?: number;
style?: StyleProp<ViewStyle>;
noBorder?: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -51,6 +52,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
backgroundColor: theme.centerChannelBg,
borderRadius: size / 2,
},
noBorder: {
borderWidth: 0,
},
notFirstAvatars: {
justifyContent: 'center',
alignItems: 'center',
@ -99,7 +103,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const UserAvatarsStack = ({breakAt = 3, channelId, location, style: baseContainerStyle, users}: Props) => {
const UserAvatarsStack = ({
breakAt = 3,
channelId,
location,
style: baseContainerStyle,
users,
noBorder = false,
}: Props) => {
const theme = useTheme();
const intl = useIntl();
const isTablet = useIsTablet();
@ -154,14 +165,14 @@ const UserAvatarsStack = ({breakAt = 3, channelId, location, style: baseContaine
{displayUsers.map((user, index) => (
<UserAvatar
key={user.id}
style={index === 0 ? style.firstAvatar : style.notFirstAvatars}
style={index === 0 ? [style.firstAvatar, noBorder && style.noBorder] : [style.notFirstAvatars, noBorder && style.noBorder]}
user={user}
/>
))}
{Boolean(overflowUsersCount) && (
<View style={style.overflowContainer}>
<View style={style.overflowItem}>
<Text style={style.overflowText} >
<View style={[style.overflowContainer, noBorder && style.noBorder]}>
<View style={[style.overflowItem, noBorder && style.noBorder]}>
<Text style={style.overflowText}>
{'+' + overflowUsersCount.toString()}
</Text>
</View>

View file

@ -22,7 +22,7 @@ export const SEARCH_INPUT_HEIGHT = Platform.select({android: 40, default: 36});
export const SEARCH_INPUT_MARGIN = 5;
export const JOIN_CALL_BAR_HEIGHT = 38;
export const CURRENT_CALL_BAR_HEIGHT = 74;
export const CURRENT_CALL_BAR_HEIGHT = 68;
export const CALL_ERROR_BAR_HEIGHT = 62;
export const ANNOUNCEMENT_BAR_HEIGHT = 40;

View file

@ -32,7 +32,7 @@ export const AudioDeviceButton = ({pressableStyle, iconStyle, buttonTextStyle, c
>
<CompassIcon
name={'volume-high'}
size={24}
size={32}
style={iconStyle}
/>
<Text style={buttonTextStyle}>{speakerLabel}</Text>

View file

@ -115,7 +115,7 @@ export const AudioDeviceButton = ({pressableStyle, iconStyle, buttonTextStyle, c
>
<CompassIcon
name={icon}
size={24}
size={32}
style={iconStyle}
/>
<Text style={buttonTextStyle}>{label}</Text>

View file

@ -2,12 +2,16 @@
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {View, StyleSheet, Text, Platform} from 'react-native';
import {View, StyleSheet, Platform} from 'react-native';
import {makeCallsTheme} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import Emoji from '@components/emoji';
import ProfilePicture from '@components/profile_picture';
import {useTheme} from '@context/theme';
import {changeOpacity} from '@utils/theme';
import type {CallsTheme} from '@calls/types/calls';
import type {EmojiData} from '@mattermost/calls/lib/types';
import type UserModel from '@typings/database/models/servers/user';
@ -15,60 +19,74 @@ type Props = {
userModel?: UserModel;
volume: number;
serverUrl: string;
size: number;
muted?: boolean;
sharingScreen?: boolean;
raisedHand?: boolean;
reaction?: EmojiData;
size?: 'm' | 'l';
}
const getStyleSheet = ({volume, muted, size}: { volume: number; muted?: boolean; size?: 'm' | 'l' }) => {
const baseSize = size === 'm' || !size ? 40 : 72;
const smallIcon = size === 'm' || !size;
const widthHeight = smallIcon ? 20 : 24;
const borderRadius = smallIcon ? 10 : 12;
const padding = smallIcon ? 1 : 2;
// Note: microSize is 32, smallSize is 72
const mediumSize = 96;
const getStyleSheet = ({
theme,
volume,
size,
}: { theme: CallsTheme; volume: number; size: number }) => {
// Note: we are using the same mute/reaction sizes for small and medium sizes
const mediumIcon = size <= mediumSize;
const muteWidthHeight = mediumIcon ? 28 : 36;
const muteBorderRadius = mediumIcon ? 14 : 18;
const reactWidthHeight = mediumIcon ? 32 : 40;
const reactBorderRadius = mediumIcon ? 16 : 20;
return StyleSheet.create({
pictureHalo: {
backgroundColor: 'rgba(61, 184, 135,' + (0.24 * volume) + ')',
height: baseSize + 16,
width: baseSize + 16,
backgroundColor: changeOpacity(theme.onlineIndicator, 0.24 * volume),
height: size + 16,
width: size + 16,
padding: 4,
marginRight: 4,
borderRadius: (baseSize + 16) / 2,
borderRadius: (size + 16) / 2,
},
pictureHalo2: {
backgroundColor: 'rgba(61, 184, 135,' + (0.32 * volume) + ')',
height: baseSize + 8,
width: baseSize + 8,
backgroundColor: changeOpacity(theme.onlineIndicator, 0.32 * volume),
height: size + 8,
width: size + 8,
padding: 3,
borderRadius: (baseSize + 8) / 2,
borderRadius: (size + 8) / 2,
},
picture: {
borderRadius: baseSize / 2,
height: baseSize,
width: baseSize,
borderRadius: size / 2,
height: size,
width: size,
marginBottom: 5,
},
profileIcon: {
color: changeOpacity(theme.buttonColor, 0.16),
},
voiceShadow: {
shadowColor: 'rgb(61, 184, 135)',
shadowOffset: {width: 0, height: 0},
shadowOpacity: 1,
shadowRadius: 10,
},
mute: {
muteIconContainer: {
position: 'absolute',
bottom: -5,
bottom: 0,
right: -5,
width: widthHeight,
height: widthHeight,
borderRadius,
padding,
backgroundColor: muted ? 'black' : '#3DB887',
borderColor: 'black',
borderWidth: 2,
color: 'white',
width: muteWidthHeight,
height: muteWidthHeight,
borderRadius: muteBorderRadius,
backgroundColor: theme.callsBg,
},
muteIcon: {
width: muteWidthHeight,
height: muteWidthHeight,
borderRadius: muteBorderRadius,
paddingTop: 6,
backgroundColor: changeOpacity(theme.buttonColor, 0.16),
color: theme.buttonColor,
textAlign: 'center',
textAlignVertical: 'center',
overflow: 'hidden',
@ -80,81 +98,101 @@ const getStyleSheet = ({volume, muted, size}: { volume: number; muted?: boolean;
},
),
},
reaction: {
muteIconUnmuted: {
backgroundColor: theme.onlineIndicator,
},
reactionContainer: {
position: 'absolute',
top: -5,
right: -8,
width: reactWidthHeight,
height: reactWidthHeight,
borderRadius: reactBorderRadius,
backgroundColor: theme.callsBg,
},
reaction: {
width: reactWidthHeight,
height: reactWidthHeight,
borderRadius: reactBorderRadius,
textAlign: 'center',
textAlignVertical: 'center',
backgroundColor: changeOpacity(theme.buttonColor, 0.16),
overflow: 'hidden',
top: 0,
right: -5,
width: widthHeight,
height: widthHeight,
borderRadius,
padding,
backgroundColor: 'black',
borderColor: 'black',
borderWidth: 2,
fontSize: smallIcon ? 10 : 12,
},
raisedHand: {
backgroundColor: 'white',
right: -5,
backgroundColor: theme.buttonColor,
color: theme.awayIndicator,
...Platform.select(
{
android: {
paddingLeft: 5,
paddingTop: 3,
color: 'rgb(255, 188, 66)',
ios: {
paddingRight: 1,
paddingTop: 5,
},
default: {
paddingLeft: 0,
paddingTop: 0,
},
},
),
},
screenSharing: {
padding: padding + 1,
backgroundColor: '#D24B4E',
color: 'white',
backgroundColor: theme.dndIndicator,
color: theme.buttonColor,
textAlign: 'center',
textAlignVertical: 'center',
paddingTop: Platform.select({ios: 3}),
paddingTop: Platform.select({ios: 5}),
},
emoji: {
paddingLeft: 1,
paddingTop: Platform.select({ios: 2, default: 1}),
paddingLeft: Platform.select({ios: 5, default: 5}),
paddingTop: Platform.select({ios: 7, default: 3}),
},
});
};
const CallAvatar = ({userModel, volume, serverUrl, sharingScreen, size, muted, raisedHand, reaction}: Props) => {
const style = useMemo(() => getStyleSheet({volume, muted, size}), [volume, muted, size]);
const profileSize = size === 'm' || !size ? 40 : 72;
const iconSize = size === 'm' || !size ? 12 : 16;
const theme = useTheme();
const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]);
const style = useMemo(() => getStyleSheet({theme: callsTheme, volume, size}), [callsTheme, volume, size]);
const iconSize = size <= mediumSize ? 18 : 24;
const reactionSize = size <= mediumSize ? 22 : 26;
const styleShadow = volume > 0 ? style.voiceShadow : undefined;
// Only show one or the other.
let topRightIcon: JSX.Element | null = null;
if (sharingScreen) {
topRightIcon = (
<CompassIcon
name={'monitor'}
size={iconSize}
style={[style.reaction, style.screenSharing]}
/>
<View style={style.reactionContainer}>
<CompassIcon
name={'monitor'}
size={reactionSize}
style={[style.reaction, style.screenSharing]}
/>
</View>
);
} else if (raisedHand) {
topRightIcon = (
<Text style={[style.reaction, style.raisedHand]}>
{'✋'}
</Text>
<View style={style.reactionContainer}>
<CompassIcon
name={'hand-right'}
size={reactionSize}
style={[style.reaction, style.raisedHand]}
/>
</View>
);
}
// An emoji will override the top right indicator.
if (reaction) {
topRightIcon = (
<View style={[style.reaction, style.emoji]}>
<Emoji
emojiName={reaction.name}
literal={reaction.literal}
size={iconSize - 3}
/>
<View style={style.reactionContainer}>
<View style={[style.reaction, style.emoji]}>
<Emoji
emojiName={reaction.name}
literal={reaction.literal}
size={reactionSize - Platform.select({ios: 6, default: 4})}
/>
</View>
</View>
);
}
@ -162,14 +200,15 @@ const CallAvatar = ({userModel, volume, serverUrl, sharingScreen, size, muted, r
const profile = userModel ? (
<ProfilePicture
author={userModel}
size={profileSize}
size={size}
showStatus={false}
url={serverUrl}
/>
) : (
<CompassIcon
name='account-outline'
size={profileSize}
size={size}
style={style.profileIcon}
/>
);
@ -178,11 +217,13 @@ const CallAvatar = ({userModel, volume, serverUrl, sharingScreen, size, muted, r
{profile}
{
muted !== undefined &&
<CompassIcon
name={muted ? 'microphone-off' : 'microphone'}
size={iconSize}
style={style.mute}
/>
<View style={style.muteIconContainer}>
<CompassIcon
name={muted ? 'microphone-off' : 'microphone'}
size={iconSize}
style={[style.muteIcon, !muted && style.muteIconUnmuted]}
/>
</View>
}
{topRightIcon}
</View>

View file

@ -50,7 +50,11 @@ const CallDuration = ({value, style, updateIntervalInSeconds}: CallDurationProps
}, [updateIntervalInSeconds]);
return (
<Text style={style}>
<Text
style={style}
numberOfLines={1}
ellipsizeMode={'clip'}
>
{formattedTime}
</Text>
);

View file

@ -20,6 +20,7 @@ const styles = StyleSheet.create({
loading: {
paddingLeft: 8,
paddingRight: 8,
marginRight: 8,
color: 'white',
height: 34,
backgroundColor: 'rgba(255, 255, 255, 0.16)',
@ -27,6 +28,7 @@ const styles = StyleSheet.create({
recording: {
paddingLeft: 8,
paddingRight: 8,
marginRight: 8,
color: 'white',
height: 34,
backgroundColor: '#D24B4E',

View file

@ -39,39 +39,39 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
},
messageStyle: {
flexDirection: 'row',
alignItems: 'center',
color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 15,
lineHeight: 20,
paddingTop: 5,
paddingBottom: 5,
},
messageText: {
flex: 1,
paddingLeft: 12,
paddingRight: 4,
},
joinCallIcon: {
padding: 12,
backgroundColor: '#339970',
borderRadius: 8,
marginRight: 5,
color: 'white',
padding: 8,
backgroundColor: theme.onlineIndicator,
borderRadius: 4,
color: theme.buttonColor,
overflow: 'hidden',
},
phoneHangupIcon: {
padding: 12,
padding: 8,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.6),
borderRadius: 8,
marginRight: 5,
color: 'white',
borderRadius: 4,
color: theme.buttonColor,
overflow: 'hidden',
},
joinCallButtonText: {
color: 'white',
color: theme.buttonColor,
...typography('Body', 75, 'SemiBold'),
},
joinCallButtonTextRestricted: {
color: changeOpacity(theme.centerChannelColor, 0.32),
},
joinCallButtonIcon: {
color: 'white',
color: theme.buttonColor,
marginRight: 5,
},
joinCallButtonIconRestricted: {
@ -79,13 +79,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
},
startedText: {
color: theme.centerChannelColor,
fontWeight: 'bold',
...typography('Body', 100, 'SemiBold'),
},
joinCallButton: {
flexDirection: 'row',
padding: 12,
backgroundColor: '#339970',
borderRadius: 8,
backgroundColor: theme.onlineIndicator,
borderRadius: 4,
alignItems: 'center',
alignContent: 'center',
},
@ -93,7 +93,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
},
timeText: {
color: theme.centerChannelColor,
...typography('Body', 75),
color: changeOpacity(theme.centerChannelColor, 0.64),
},
endCallInfo: {
flexDirection: 'row',
@ -147,7 +148,7 @@ export const CallsCustomMessage = ({
<View style={style.messageStyle}>
<CompassIcon
name='phone-hangup'
size={16}
size={24}
style={style.phoneHangupIcon}
/>
<View style={style.messageText}>
@ -190,7 +191,7 @@ export const CallsCustomMessage = ({
<View style={style.messageStyle}>
<CompassIcon
name='phone-in-talk'
size={16}
size={24}
style={style.joinCallIcon}
/>
<View style={style.messageText}>
@ -212,8 +213,8 @@ export const CallsCustomMessage = ({
onPress={joinHandler}
>
<CompassIcon
name='phone-outline'
size={16}
name='phone'
size={14}
style={[style.joinCallButtonIcon, isLimitRestricted && style.joinCallButtonIconRestricted]}
/>
{

View file

@ -32,7 +32,7 @@ const ChannelInfoEnableCalls = ({channelId, enabled}: Props) => {
<OptionItem
action={preventDoubleTap(tryOnPress)}
label={(enabled ? disableText : enableText) + msgPostfix}
icon='phone-outline'
icon='phone'
type='default'
testID='channel_info.options.enable_disable_calls.option'
/>

View file

@ -54,7 +54,7 @@ const ChannelInfoStartButton = ({
<OptionBox
onPress={preventDoubleTap(tryJoin)}
text={startText + msgPostfix}
iconName='phone-outline'
iconName='phone'
activeText={joinText + msgPostfix}
activeIconName='phone-in-talk'
isActive={isACallInCurrentChannel}

View file

@ -1,25 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {View, Text, TouchableOpacity, Pressable, Platform} from 'react-native';
import {View, Text, Pressable, Platform} from 'react-native';
import {muteMyself, unmuteMyself} from '@calls/actions';
import {leaveCall, muteMyself, unmuteMyself} from '@calls/actions';
import {recordingAlert, recordingWillBePostedAlert, recordingErrorAlert} from '@calls/alerts';
import CallAvatar from '@calls/components/call_avatar';
import CallDuration from '@calls/components/call_duration';
import PermissionErrorBar from '@calls/components/permission_error_bar';
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
import {usePermissionsChecker} from '@calls/hooks';
import {makeCallsTheme} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import {Screens} from '@constants';
import {CURRENT_CALL_BAR_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {allOrientations, dismissAllModalsAndPopToScreen} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {displayUsername} from '@utils/user';
import type {CurrentCall} from '@calls/types/calls';
import type {CallsTheme, CurrentCall} from '@calls/types/calls';
import type UserModel from '@typings/database/models/servers/user';
import type {Options} from 'react-native-navigation';
@ -32,56 +35,94 @@ type Props = {
threadScreen?: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => {
return {
wrapper: {
padding: 10,
marginTop: 8,
marginRight: 6,
marginBottom: 8,
marginLeft: 6,
backgroundColor: theme.callsBg,
borderRadius: 8,
},
container: {
flexDirection: 'row',
backgroundColor: '#3F4350',
width: '100%',
borderRadius: 5,
padding: 4,
height: CURRENT_CALL_BAR_HEIGHT - 10,
alignItems: 'center',
backgroundColor: changeOpacity(theme.buttonColor, 0.08),
borderRadius: 8,
borderWidth: 2,
borderStyle: 'solid',
borderColor: changeOpacity(theme.buttonColor, 0.16),
width: '100%',
paddingTop: 8,
paddingRight: 12,
paddingBottom: 8,
paddingLeft: 12,
height: CURRENT_CALL_BAR_HEIGHT - 10,
},
pressable: {
zIndex: 10,
},
profilePic: {
marginTop: 4,
marginRight: Platform.select({android: -8}),
marginLeft: Platform.select({android: -8}),
},
userInfo: {
flex: 1,
paddingLeft: 10,
paddingLeft: 6,
},
speakingUser: {
color: theme.sidebarText,
fontWeight: '600',
fontSize: 16,
color: theme.buttonColor,
...typography('Body', 200, 'SemiBold'),
},
currentChannel: {
color: theme.sidebarText,
opacity: 0.64,
speakingPostfix: {
...typography('Body', 200, 'Regular'),
},
channelAndTime: {
color: changeOpacity(theme.buttonColor, 0.56),
...typography('Body', 75, 'Regular'),
},
separator: {
color: changeOpacity(theme.buttonColor, 0.32),
...typography('Body', 75, 'Regular'),
},
buttonContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-evenly',
},
micIconContainer: {
width: 42,
height: 42,
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: theme.onlineIndicator,
borderRadius: 4,
margin: 4,
padding: 9,
borderRadius: 20,
},
micIcon: {
color: theme.sidebarText,
color: changeOpacity(theme.buttonColor, 0.56),
},
muted: {
backgroundColor: 'transparent',
backgroundColor: changeOpacity(theme.buttonColor, 0.08),
},
expandIcon: {
color: theme.sidebarText,
padding: 8,
marginRight: 8,
verticalLine: {
height: 42,
width: 1,
backgroundColor: changeOpacity(theme.buttonColor, 0.16),
marginLeft: 12,
marginRight: 12,
},
hangupIconContainer: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: theme.dndIndicator,
borderRadius: 20,
},
hangupIcon: {
color: theme.buttonColor,
},
};
});
@ -95,7 +136,8 @@ const CurrentCallBar = ({
threadScreen,
}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]);
const style = getStyleSheet(callsTheme);
const intl = useIntl();
const {formatMessage} = intl;
usePermissionsChecker(micPermissionsGranted);
@ -118,21 +160,35 @@ const CurrentCallBar = ({
await dismissAllModalsAndPopToScreen(Screens.CALL, title, {fromThreadScreen: threadScreen}, options);
}, [formatMessage, threadScreen]);
const leaveCallHandler = useCallback(() => {
leaveCall();
}, []);
const myParticipant = currentCall?.participants[currentCall.myUserId];
// Since we can only see one user talking, it doesn't really matter who we show here (e.g., we can't
// tell who is speaking louder).
const talkingUsers = Object.keys(currentCall?.voiceOn || {});
const speaker = talkingUsers.length > 0 ? talkingUsers[0] : '';
let talkingMessage = formatMessage({
id: 'mobile.calls_noone_talking',
defaultMessage: 'No one is talking',
});
let talkingMessage = (
<Text style={style.speakingUser}>
{formatMessage({
id: 'mobile.calls_noone_talking',
defaultMessage: 'No one is talking',
})}
</Text>);
if (speaker) {
talkingMessage = formatMessage({
id: 'mobile.calls_name_is_talking',
defaultMessage: '{name} is talking',
}, {name: displayUsername(userModelsDict[speaker], intl.locale, teammateNameDisplay)});
talkingMessage = (
<Text style={style.speakingUser}>
{displayUsername(userModelsDict[speaker], intl.locale, teammateNameDisplay)}
{' '}
<Text style={style.speakingPostfix}>{
formatMessage({
id: 'mobile.calls_name_is_talking_postfix',
defaultMessage: 'is talking...',
})}
</Text>
</Text>);
}
const muteUnmute = () => {
@ -166,39 +222,56 @@ const CurrentCallBar = ({
return (
<>
<View style={style.wrapper}>
<View style={style.container}>
<CallAvatar
userModel={userModelsDict[speaker || '']}
volume={speaker ? 0.5 : 0}
serverUrl={currentCall?.serverUrl || ''}
/>
<View style={style.userInfo}>
<Text style={style.speakingUser}>{talkingMessage}</Text>
<Text style={style.currentChannel}>{`~${displayName}`}</Text>
<Pressable
style={style.container}
onPress={goToCallScreen}
>
<View style={style.profilePic}>
<CallAvatar
userModel={userModelsDict[speaker || '']}
volume={speaker ? 0.5 : 0}
serverUrl={currentCall?.serverUrl || ''}
size={32}
/>
</View>
<Pressable
onPressIn={goToCallScreen}
style={style.pressable}
>
<CompassIcon
name='arrow-expand'
size={24}
style={style.expandIcon}
/>
</Pressable>
<TouchableOpacity
onPress={muteUnmute}
style={[style.pressable, style.micIconContainer, myParticipant?.muted && style.muted]}
disabled={!micPermissionsGranted}
>
<UnavailableIconWrapper
name={myParticipant?.muted ? 'microphone-off' : 'microphone'}
size={24}
unavailable={!micPermissionsGranted}
style={[style.micIcon]}
/>
</TouchableOpacity>
</View>
<View style={style.userInfo}>
{talkingMessage}
<Text style={style.channelAndTime}>
{`~${displayName}`}
<Text style={style.separator}>{' • '}</Text>
<CallDuration
style={style.channelAndTime}
value={currentCall?.startTime || Date.now()}
updateIntervalInSeconds={1}
/>
</Text>
</View>
<View style={style.buttonContainer}>
<Pressable
onPress={muteUnmute}
style={[style.pressable, style.micIconContainer, myParticipant?.muted && style.muted]}
disabled={!micPermissionsGranted}
>
<UnavailableIconWrapper
name={myParticipant?.muted ? 'microphone-off' : 'microphone'}
size={24}
unavailable={!micPermissionsGranted}
style={style.micIcon}
/>
</Pressable>
<View style={style.verticalLine}/>
<Pressable
onPress={leaveCallHandler}
style={[style.pressable, style.hangupIconContainer]}
>
<CompassIcon
name='phone-hangup'
size={24}
style={style.hangupIcon}
/>
</Pressable>
</View>
</Pressable>
</View>
{micPermissionsError && <PermissionErrorBar/>}
</>

View file

@ -1,11 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useMemo} from 'react';
import {StyleSheet, View} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import EmojiPill from '@calls/components/emoji_pill';
import {makeCallsTheme} from '@calls/utils';
import {useTheme} from '@context/theme';
import {changeOpacity} from '@utils/theme';
import type {ReactionStreamEmoji} from '@calls/types/calls';
@ -35,7 +38,6 @@ const styles = StyleSheet.create({
const gradient = {
start: {x: 0.75, y: 0},
end: {x: 1, y: 0},
colors: ['#00000000', '#000000'],
};
interface Props {
@ -43,6 +45,9 @@ interface Props {
}
const EmojiList = ({reactionStream}: Props) => {
const theme = useTheme();
const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]);
return (
<View style={styles.container}>
<View style={styles.emojiList}>
@ -58,7 +63,7 @@ const EmojiList = ({reactionStream}: Props) => {
<LinearGradient
start={gradient.start}
end={gradient.end}
colors={gradient.colors}
colors={[changeOpacity(callsTheme.callsBg, 0), callsTheme.callsBg]}
style={styles.gradient}
/>
</View>

View file

@ -5,7 +5,10 @@ import React from 'react';
import {View, Platform, StyleSheet} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CurrentCallBar from '@calls/components/current_call_bar';
import JoinCallBanner from '@calls/components/join_call_banner';
import {DEFAULT_HEADER_HEIGHT} from '@constants/view';
import {useServerUrl} from '@context/server';
const topBarHeight = DEFAULT_HEADER_HEIGHT;
@ -25,11 +28,14 @@ const style = StyleSheet.create({
});
type Props = {
children: React.ReactNode;
channelId: string;
showJoinCallBanner: boolean;
isInACall: boolean;
threadScreen?: boolean;
}
const FloatingCallContainer = ({threadScreen, ...props}: Props) => {
const FloatingCallContainer = ({channelId, showJoinCallBanner, isInACall, threadScreen}: Props) => {
const serverUrl = useServerUrl();
const insets = useSafeAreaInsets();
const wrapperTop = {
top: insets.top + (threadScreen ? 0 : topBarHeight),
@ -37,7 +43,13 @@ const FloatingCallContainer = ({threadScreen, ...props}: Props) => {
return (
<View style={[style.wrapper, wrapperTop]}>
{props.children}
{showJoinCallBanner &&
<JoinCallBanner
serverUrl={serverUrl}
channelId={channelId}
/>
}
{isInACall && <CurrentCallBar/>}
</View>
);
};

View file

@ -14,6 +14,7 @@ import Screens from '@constants/screens';
import {JOIN_CALL_BAR_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {LimitRestrictedInfo} from '@calls/observers';
import type UserModel from '@typings/database/models/servers/user';
@ -28,13 +29,18 @@ type Props = {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
outerContainer: {
backgroundColor: theme.centerChannelBg,
backgroundColor: theme.sidebarBg,
},
innerContainer: {
flexDirection: 'row',
backgroundColor: '#3DB887',
backgroundColor: '#339970', // intentionally not themed
width: '100%',
padding: 5,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 12,
paddingRight: 12,
justifyContent: 'center',
alignItems: 'center',
height: JOIN_CALL_BAR_HEIGHT,
@ -43,19 +49,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
backgroundColor: changeOpacity(theme.centerChannelColor, 0.48),
},
joinCallIcon: {
color: theme.sidebarText,
marginLeft: 10,
marginRight: 5,
color: theme.buttonColor,
marginRight: 7,
},
joinCall: {
color: theme.sidebarText,
fontWeight: 'bold',
fontSize: 16,
color: theme.buttonColor,
...typography('Body', 100, 'SemiBold'),
},
started: {
flex: 1,
color: theme.sidebarText,
fontWeight: '400',
color: changeOpacity(theme.buttonColor, 0.84),
...typography(),
marginLeft: 10,
},
limitReached: {
@ -63,12 +67,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
display: 'flex',
textAlign: 'right',
marginRight: 10,
color: '#FFFFFFD6',
color: changeOpacity(theme.sidebarText, 0.84),
fontWeight: '400',
},
avatars: {
marginRight: 5,
},
headerText: {
color: changeOpacity(theme.centerChannelColor, 0.56),
fontSize: 12,
@ -107,7 +108,7 @@ const JoinCallBanner = ({
>
<CompassIcon
name='phone-in-talk'
size={16}
size={18}
style={style.joinCallIcon}
/>
<FormattedText
@ -128,14 +129,13 @@ const JoinCallBanner = ({
style={style.started}
/>
)}
<View style={style.avatars}>
<UserAvatarsStack
channelId={channelId}
location={Screens.CHANNEL}
users={participants}
breakAt={1}
/>
</View>
<UserAvatarsStack
channelId={channelId}
location={Screens.CHANNEL}
users={participants}
breakAt={1}
noBorder={true}
/>
</Pressable>
</View>
);

View file

@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {DEFAULT_HEADER_HEIGHT, JOIN_CALL_BAR_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
threadScreen?: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: '#339970', // intentionally not themed
height: 40,
width: '100%',
position: 'absolute',
},
content: {
backgroundColor: theme.centerChannelBg,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
flex: 1,
},
}));
export const RoundedHeaderCalls = ({threadScreen}: Props) => {
const theme = useTheme();
const insets = useSafeAreaInsets();
const styles = getStyleSheet(theme);
const containerTop = {
top: insets.top + (threadScreen ? JOIN_CALL_BAR_HEIGHT : JOIN_CALL_BAR_HEIGHT + DEFAULT_HEADER_HEIGHT),
};
return (
<View style={[styles.container, containerTop]}>
<View style={styles.content}/>
</View>
);
};

View file

@ -1,35 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import React, {useCallback, useMemo} from 'react';
import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native';
import {raiseHand, unraiseHand} from '@calls/actions';
import {sendReaction} from '@calls/actions/calls';
import EmojiButton from '@calls/components/emoji_button';
import {makeCallsTheme} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
const styles = StyleSheet.create({
import type {CallsTheme} from '@calls/types/calls';
const getStyleSheet = ((theme: CallsTheme) => StyleSheet.create({
outerContainer: {
flexDirection: 'row',
},
container: {
display: 'flex',
flex: 1,
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
width: '100%',
height: 64,
paddingLeft: 16,
paddingRight: 16,
},
containerInLandscape: {
paddingBottom: 6,
containerLandscape: {
height: 60,
paddingBottom: 12,
justifyContent: 'center',
},
button: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.08)',
backgroundColor: changeOpacity(theme.buttonColor, 0.08),
borderRadius: 30,
height: 48,
maxWidth: 160,
@ -41,18 +50,19 @@ const styles = StyleSheet.create({
marginLeft: 12,
},
buttonPressed: {
backgroundColor: 'rgba(245, 171, 0, 0.24)',
backgroundColor: theme.buttonColor,
},
unPressed: {
color: 'white',
color: changeOpacity(theme.buttonColor, 0.56),
},
pressed: {
color: '#F5AB00',
color: theme.callsBg,
},
buttonText: {
marginLeft: 8,
...typography('Body', 200, 'SemiBold'),
},
});
}));
const predefinedReactions = [['+1', '1F44D'], ['clap', '1F44F'], ['joy', '1F602'], ['heart', '2764-FE0F']];
@ -61,6 +71,9 @@ interface Props {
}
const ReactionBar = ({raisedHand}: Props) => {
const theme = useTheme();
const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]);
const style = getStyleSheet(callsTheme);
const {width, height} = useWindowDimensions();
const isLandscape = width > height;
@ -68,13 +81,13 @@ const ReactionBar = ({raisedHand}: Props) => {
<FormattedText
id={'mobile.calls_lower_hand'}
defaultMessage={'Lower hand'}
style={[styles.buttonText, raisedHand ? styles.pressed : styles.unPressed]}
style={[style.buttonText, raisedHand ? style.pressed : style.unPressed]}
/>);
const RaiseHandText = (
<FormattedText
id={'mobile.calls_raise_hand'}
defaultMessage={'Raise hand'}
style={[styles.buttonText, raisedHand ? styles.pressed : styles.unPressed]}
style={[style.buttonText, raisedHand ? style.pressed : style.unPressed]}
/>);
const toggleRaiseHand = useCallback(() => {
@ -87,28 +100,30 @@ const ReactionBar = ({raisedHand}: Props) => {
}, [raisedHand]);
return (
<View style={[styles.container, isLandscape && styles.containerInLandscape]}>
<Pressable
style={[styles.button, isLandscape && styles.buttonLandscape, Boolean(raisedHand) && styles.buttonPressed]}
onPress={toggleRaiseHand}
>
<CompassIcon
name={raisedHand ? 'hand-right-outline-off' : 'hand-right-outline'}
size={24}
style={[raisedHand ? styles.pressed : styles.unPressed]}
/>
{raisedHand ? LowerHandText : RaiseHandText}
</Pressable>
{
predefinedReactions.map(([name, unified]) => (
<EmojiButton
key={name}
emojiName={name}
style={[styles.button, isLandscape && styles.buttonLandscape]}
onPress={() => sendReaction({name, unified})}
<View style={style.outerContainer}>
<View style={[style.container, isLandscape && style.containerLandscape]}>
<Pressable
style={[style.button, isLandscape && style.buttonLandscape, Boolean(raisedHand) && style.buttonPressed]}
onPress={toggleRaiseHand}
>
<CompassIcon
name={raisedHand ? 'hand-right-outline-off' : 'hand-right'}
size={24}
style={[raisedHand ? style.pressed : style.unPressed]}
/>
))
}
{raisedHand ? LowerHandText : RaiseHandText}
</Pressable>
{
predefinedReactions.map(([name, unified]) => (
<EmojiButton
key={name}
emojiName={name}
style={[style.button, isLandscape && style.buttonLandscape]}
onPress={() => sendReaction({name, unified})}
/>
))
}
</View>
</View>
);
};

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {type StyleProp, type TextStyle, View} from 'react-native';
import {type StyleProp, type TextStyle, View, type ViewStyle} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
@ -13,6 +13,7 @@ type Props = {
size: number;
style: StyleProp<TextStyle>;
unavailable: boolean;
errorContainerStyle?: StyleProp<ViewStyle>;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -21,16 +22,16 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
position: 'relative',
},
unavailable: {
color: changeOpacity(theme.sidebarText, 0.32),
color: changeOpacity(theme.buttonColor, 0.32),
},
errorContainer: {
position: 'absolute',
right: 0,
backgroundColor: '#3F4350',
backgroundColor: theme.centerChannelColor,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 0.5,
borderColor: '#3F4350',
borderColor: theme.centerChannelColor,
},
errorIcon: {
color: theme.dndIndicator,
@ -38,7 +39,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const UnavailableIconWrapper = ({name, size, style: providedStyle, unavailable}: Props) => {
const UnavailableIconWrapper = ({name, size, style: providedStyle, unavailable, errorContainerStyle}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const errorIconSize = size / 2;
@ -52,7 +53,11 @@ const UnavailableIconWrapper = ({name, size, style: providedStyle, unavailable}:
/>
{unavailable &&
<View
style={[style.errorContainer, {borderRadius: errorIconSize / 2}]}
style={[
style.errorContainer,
errorContainerStyle,
{borderRadius: errorIconSize / 2},
]}
>
<CompassIcon
name={'close-circle'}

View file

@ -1,16 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {
DeviceEventEmitter,
Keyboard,
type LayoutChangeEvent,
type LayoutRectangle,
NativeModules,
Platform,
Pressable,
SafeAreaView,
ScrollView,
StatusBar,
Text,
useWindowDimensions,
View,
@ -31,8 +34,9 @@ import PermissionErrorBar from '@calls/components/permission_error_bar';
import ReactionBar from '@calls/components/reaction_bar';
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
import {usePermissionsChecker} from '@calls/hooks';
import {RaisedHandBanner} from '@calls/screens/call_screen/raised_hand_banner';
import {useCallsConfig} from '@calls/state';
import {sortParticipants} from '@calls/utils';
import {getHandsRaised, makeCallsTheme, sortParticipants} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
@ -57,11 +61,17 @@ import {freezeOtherScreens} from '@utils/gallery';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {mergeNavigationOptions} from '@utils/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {displayUsername} from '@utils/user';
import type {CallParticipant, CurrentCall} from '@calls/types/calls';
import type {CallParticipant, CallsTheme, CurrentCall} from '@calls/types/calls';
import type {AvailableScreens} from '@typings/screens/navigation';
const avatarL = 96;
const avatarM = 72;
const usernameL = 110;
const usernameM = 92;
export type Props = {
componentId: AvailableScreens;
currentCall: CurrentCall | null;
@ -71,9 +81,10 @@ export type Props = {
fromThreadScreen?: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => ({
wrapper: {
flex: 1,
backgroundColor: theme.callsBg,
},
container: {
...Platform.select({
@ -85,32 +96,26 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}),
flexDirection: 'column',
backgroundColor: 'black',
backgroundColor: theme.callsBg,
width: '100%',
height: '100%',
borderRadius: 5,
alignItems: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',
width: '100%',
paddingTop: 10,
paddingLeft: 14,
paddingRight: 14,
...Platform.select({
android: {
elevation: 4,
},
ios: {
zIndex: 4,
},
}),
height: 56,
paddingLeft: 24,
paddingRight: 16,
},
headerPortraitSpacer: {
height: 12,
},
headerLandscape: {
position: 'absolute',
top: 0,
backgroundColor: 'rgba(0,0,0,0.64)',
backgroundColor: 'rgba(0,0,0,0.5)', // not themed
height: 52,
paddingTop: 0,
},
@ -118,56 +123,83 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
top: -1000,
},
time: {
flex: 1,
color: theme.sidebarText,
color: theme.buttonColor,
...typography('Heading', 200),
width: 60,
},
collapseIconContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 48,
height: 48,
},
collapseIcon: {
color: changeOpacity(theme.buttonColor, 0.56),
},
collapseIconLandscape: {
margin: 10,
padding: 10,
padding: 0,
backgroundColor: 'transparent',
borderRadius: 0,
},
usersScrollContainer: {
flex: 1,
width: '100%',
},
usersScrollContainerScreenOn: {
marginTop: -20,
},
usersScrollViewCentered: {
flex: 1,
justifyContent: 'center',
},
users: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
alignContent: 'flex-start',
},
usersScrollLandscapeScreenOn: {
position: 'absolute',
height: 0,
},
user: {
flexGrow: 1,
flexDirection: 'column',
alignItems: 'center',
marginTop: 10,
marginBottom: 10,
marginLeft: 10,
marginRight: 10,
margin: 10,
},
userScreenOn: {
marginTop: 0,
marginTop: 5,
marginBottom: 0,
},
username: {
color: theme.sidebarText,
marginTop: 10,
width: usernameL,
textAlign: 'center',
color: theme.buttonColor,
...typography('Body', 100, 'SemiBold'),
},
usernameShort: {
marginTop: 0,
width: usernameM,
},
buttonsContainer: {
alignItems: 'center',
},
buttons: {
flexDirection: 'column',
backgroundColor: 'rgba(255,255,255,0.16)',
width: '100%',
paddingBottom: 10,
...Platform.select({
android: {
elevation: 4,
},
ios: {
zIndex: 4,
},
}),
alignItems: 'center',
paddingBottom: 12,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
gap: 4,
backgroundColor: changeOpacity(theme.buttonColor, 0.08),
},
buttonsLandscape: {
height: 110,
position: 'absolute',
backgroundColor: 'rgba(0,0,0,0.64)',
backgroundColor: 'rgba(0,0,0,0.5)', // not themed
width: '100%',
bottom: 0,
paddingTop: 16,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
buttonsLandscapeWithReactions: {
height: 174,
@ -184,25 +216,26 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
flex: 0,
},
mute: {
flexDirection: 'column',
alignSelf: 'stretch',
alignItems: 'center',
gap: 4,
padding: 24,
backgroundColor: '#3DB887',
backgroundColor: theme.onlineIndicator,
borderRadius: 20,
marginBottom: 10,
marginTop: 20,
marginLeft: 16,
marginRight: 16,
marginTop: 20,
marginBottom: 20,
},
muteMuted: {
backgroundColor: 'rgba(255,255,255,0.16)',
backgroundColor: changeOpacity(theme.buttonColor, 0.12),
},
speakerphoneIcon: {
color: theme.sidebarText,
backgroundColor: 'rgba(255,255,255,0.12)',
backgroundColor: changeOpacity(theme.buttonColor, 0.12),
},
buttonOn: {
color: 'black',
color: theme.callsBg,
backgroundColor: 'white',
},
otherButtons: {
@ -212,58 +245,47 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
otherButtonsLandscape: {
justifyContent: 'center',
},
collapseIcon: {
color: theme.sidebarText,
margin: 10,
padding: 10,
backgroundColor: 'rgba(255,255,255,0.12)',
borderRadius: 4,
overflow: 'hidden',
},
collapseIconLandscape: {
margin: 10,
padding: 0,
backgroundColor: 'transparent',
borderRadius: 0,
},
muteIcon: {
color: theme.sidebarText,
color: theme.buttonColor,
},
muteIconLandscape: {
backgroundColor: '#3DB887',
backgroundColor: theme.onlineIndicator,
padding: 11,
},
muteIconLandscapeMuted: {
backgroundColor: 'rgba(255,255,255,0.16)',
backgroundColor: changeOpacity(theme.buttonColor, 0.12),
},
buttonText: {
color: theme.sidebarText,
color: changeOpacity(theme.buttonColor, 0.72),
...typography('Body', 75, 'SemiBold'),
},
buttonIcon: {
color: theme.sidebarText,
backgroundColor: 'rgba(255,255,255,0.12)',
color: theme.buttonColor,
backgroundColor: changeOpacity(theme.buttonColor, 0.08),
borderRadius: 34,
padding: 22,
padding: 18,
width: 68,
height: 68,
margin: 10,
marginBottom: 8,
overflow: 'hidden',
},
buttonIconLandscape: {
borderRadius: 26,
paddingTop: 14,
paddingRight: 16,
paddingBottom: 16,
paddingLeft: 14,
padding: 10,
width: 52,
height: 52,
marginLeft: 12,
marginRight: 12,
},
errorContainerLandscape: {
right: 20,
top: 10,
},
hangUpIcon: {
backgroundColor: Preferences.THEMES.denim.dndIndicator,
},
screenShareImage: {
flex: 7,
flex: 2,
width: '100%',
height: '100%',
alignItems: 'center',
@ -273,7 +295,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
margin: 3,
},
unavailableText: {
color: changeOpacity(theme.sidebarText, 0.32),
color: changeOpacity(theme.buttonColor, 0.32),
},
denimDND: {
color: Preferences.THEMES.denim.dndIndicator,
@ -298,11 +320,18 @@ const CallScreen = ({
usePermissionsChecker(micPermissionsGranted);
const [showControlsInLandscape, setShowControlsInLandscape] = useState(false);
const [showReactions, setShowReactions] = useState(false);
const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]);
const style = getStyleSheet(callsTheme);
const [centerUsers, setCenterUsers] = useState(false);
const [layout, setLayout] = useState<LayoutRectangle | null>(null);
const style = getStyleSheet(theme);
const isLandscape = width > height;
const myParticipant = currentCall?.participants[currentCall.myUserId];
const micPermissionsError = !micPermissionsGranted && !currentCall?.micPermissionsErrorDismissed;
const screenShareOn = Boolean(currentCall?.screenOn);
const isLandscape = width > height;
const smallerAvatar = isLandscape || screenShareOn;
const avatarSize = smallerAvatar ? avatarM : avatarL;
const numParticipants = Object.keys(participantsDict).length;
const callThreadOptionTitle = intl.formatMessage({id: 'mobile.calls_call_thread', defaultMessage: 'Call Thread'});
const recordOptionTitle = intl.formatMessage({id: 'mobile.calls_record', defaultMessage: 'Record'});
@ -318,7 +347,7 @@ const CallScreen = ({
useEffect(() => {
mergeNavigationOptions('Call', {
layout: {
componentBackgroundColor: 'black',
componentBackgroundColor: callsTheme.callsBg,
orientation: allOrientations,
},
topBar: {
@ -426,13 +455,15 @@ const CallScreen = ({
// The user should see the loading only if:
// - Recording has been initialized, recording has not been started, and recording has not ended
const waitingForRecording = Boolean(currentCall?.recState?.init_at && !currentCall.recState.start_at && !currentCall.recState.end_at && isHost);
const showStartRecording = isHost && EnableRecordings && !(waitingForRecording || recording);
const showStopRecording = isHost && EnableRecordings && (waitingForRecording || recording);
const showOtherActions = useCallback(async () => {
const renderContent = () => {
return (
<View>
{
isHost && EnableRecordings && !(waitingForRecording || recording) &&
showStartRecording &&
<SlideUpPanelItem
icon={'record-circle-outline'}
onPress={startRecording}
@ -440,7 +471,7 @@ const CallScreen = ({
/>
}
{
isHost && EnableRecordings && (waitingForRecording || recording) &&
showStopRecording &&
<SlideUpPanelItem
icon={'record-square-outline'}
onPress={stopRecording}
@ -493,6 +524,31 @@ const CallScreen = ({
return () => didDismissListener.remove();
}, [isTablet]);
useEffect(() => {
if (!layout || !layout.height || !layout.width) {
return;
}
const avatarCellHeight = avatarSize + 20 + 20 + 20; // avatar + name + host pill + padding
const usernameSize = smallerAvatar ? usernameM : usernameL;
const avatarCellWidth = usernameSize + 20; // name width + padding
const perRow = Math.floor(layout.width / avatarCellWidth);
const totalHeight = Math.ceil(numParticipants / perRow) * avatarCellHeight;
const totalWidth = numParticipants * avatarCellWidth;
// If screenShareOn, we care about width, otherwise we care about height.
if ((screenShareOn && totalWidth > layout.width) || (!screenShareOn && totalHeight > layout.height)) {
setCenterUsers(false);
} else {
setCenterUsers(true);
}
}, [layout, numParticipants]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
setLayout(e.nativeEvent.layout);
}, []);
if (!currentCall || !myParticipant) {
// Note: this happens because the screen is "rendered", even after the screen has been popped, and the
// currentCall will have already been set to null when those extra renders run. We probably don't ever need
@ -505,7 +561,7 @@ const CallScreen = ({
}
let screenShareView = null;
if (currentCall.screenShareURL && currentCall.screenOn) {
if (currentCall.screenShareURL && screenShareOn) {
screenShareView = (
<Pressable
testID='screen-share-container'
@ -528,48 +584,55 @@ const CallScreen = ({
);
}
const participants = sortParticipants(teammateNameDisplay, participantsDict, currentCall.screenOn);
const raisedHands = getHandsRaised(participantsDict);
const participants = sortParticipants(intl.locale, teammateNameDisplay, participantsDict, currentCall.screenOn);
let usersList = null;
if (!currentCall.screenOn || !isLandscape) {
if (!screenShareOn || !isLandscape) {
usersList = (
<ScrollView
alwaysBounceVertical={false}
horizontal={currentCall.screenOn !== ''}
contentContainerStyle={[isLandscape && Boolean(currentCall.screenOn) && style.usersScrollLandscapeScreenOn]}
>
<Pressable
testID='users-list'
onPress={toggleControlsInLandscape}
style={style.users}
<View style={[style.usersScrollContainer, screenShareOn && style.usersScrollContainerScreenOn]}>
<ScrollView
alwaysBounceVertical={false}
horizontal={screenShareOn}
onLayout={onLayout}
contentContainerStyle={centerUsers && style.usersScrollViewCentered}
>
{participants.map((user) => {
return (
<View
style={[style.user, Boolean(currentCall.screenOn) && style.userScreenOn]}
key={user.id}
>
<CallAvatar
userModel={user.userModel}
volume={currentCall.voiceOn[user.id] ? 1 : 0}
muted={user.muted}
sharingScreen={user.id === currentCall.screenOn}
raisedHand={Boolean(user.raisedHand)}
reaction={user.reaction?.emoji}
size={currentCall.screenOn ? 'm' : 'l'}
serverUrl={currentCall.serverUrl}
/>
<Text style={style.username}>
{displayUsername(user.userModel, intl.locale, teammateNameDisplay)}
{user.id === myParticipant.id &&
` ${intl.formatMessage({id: 'mobile.calls_you', defaultMessage: '(you)'})}`
}
</Text>
{user.id === currentCall.hostId && <CallsBadge type={CallsBadgeType.Host}/>}
</View>
);
})}
</Pressable>
</ScrollView>
<Pressable
testID='users-list'
onPress={toggleControlsInLandscape}
style={style.users}
>
{participants.map((user) => {
return (
<View
style={[style.user, screenShareOn && style.userScreenOn]}
key={user.id}
>
<CallAvatar
userModel={user.userModel}
volume={currentCall.voiceOn[user.id] ? 1 : 0}
muted={user.muted}
sharingScreen={user.id === currentCall.screenOn}
raisedHand={Boolean(user.raisedHand)}
reaction={user.reaction?.emoji}
size={avatarSize}
serverUrl={currentCall.serverUrl}
/>
<Text
style={[style.username, smallerAvatar && style.usernameShort]}
numberOfLines={1}
>
{displayUsername(user.userModel, intl.locale, teammateNameDisplay)}
{user.id === myParticipant.id &&
` ${intl.formatMessage({id: 'mobile.calls_you', defaultMessage: '(you)'})}`
}
</Text>
{user.id === currentCall.hostId && <CallsBadge type={CallsBadgeType.Host}/>}
</View>
);
})}
</Pressable>
</ScrollView>
</View>
);
}
@ -586,141 +649,205 @@ const CallScreen = ({
style={[style.buttonText, !micPermissionsGranted && style.unavailableText]}
/>);
const header = (
<View
style={[
style.header,
isLandscape && style.headerLandscape,
isLandscape && !showControlsInLandscape && style.headerLandscapeNoControls,
]}
>
{waitingForRecording && <CallsBadge type={CallsBadgeType.Waiting}/>}
{recording && <CallsBadge type={CallsBadgeType.Rec}/>}
<CallDuration
style={style.time}
value={currentCall.startTime}
updateIntervalInSeconds={1}
/>
<RaisedHandBanner
raisedHands={raisedHands}
currentUserId={currentCall.myUserId}
teammateNameDisplay={teammateNameDisplay}
/>
<Pressable
onPress={() => popTopScreen()}
style={style.collapseIconContainer}
>
<CompassIcon
name='arrow-collapse'
size={28}
style={[style.collapseIcon, isLandscape && style.collapseIconLandscape]}
/>
</Pressable>
</View>
);
return (
<SafeAreaView style={style.wrapper}>
<StatusBar barStyle={'light-content'}/>
<View style={style.container}>
<View
style={[
style.header,
isLandscape && style.headerLandscape,
isLandscape && !showControlsInLandscape && style.headerLandscapeNoControls,
]}
>
{waitingForRecording && <CallsBadge type={CallsBadgeType.Waiting}/>}
{recording && <CallsBadge type={CallsBadgeType.Rec}/>}
<CallDuration
style={style.time}
value={currentCall.startTime}
updateIntervalInSeconds={1}
/>
<Pressable onPress={() => popTopScreen()}>
<CompassIcon
name='arrow-collapse'
size={24}
style={[style.collapseIcon, isLandscape && style.collapseIconLandscape]}
/>
</Pressable>
</View>
{!isLandscape && header}
{!isLandscape && <View style={style.headerPortraitSpacer}/>}
{usersList}
{screenShareView}
{micPermissionsError && <PermissionErrorBar/>}
{!isLandscape &&
{isLandscape && header}
{!isLandscape && currentCall.reactionStream.length > 0 &&
<EmojiList reactionStream={currentCall.reactionStream}/>
}
<View
style={[
style.buttons,
isLandscape && style.buttonsLandscape,
isLandscape && showReactions && style.buttonsLandscapeWithReactions,
isLandscape && !showControlsInLandscape && style.buttonsLandscapeNoControls,
]}
>
{showReactions &&
<ReactionBar raisedHand={myParticipant.raisedHand}/>
}
{!isLandscape &&
<Pressable
testID='mute-unmute'
style={[style.mute, myParticipant.muted && style.muteMuted]}
onPress={muteUnmuteHandler}
disabled={!micPermissionsGranted}
>
<UnavailableIconWrapper
name={myParticipant.muted ? 'microphone-off' : 'microphone'}
size={24}
unavailable={!micPermissionsGranted}
style={style.muteIcon}
/>
{myParticipant.muted ? UnmuteText : MuteText}
</Pressable>
}
<View style={[style.otherButtons, isLandscape && style.otherButtonsLandscape]}>
<Pressable
testID='leave'
style={[style.button, isLandscape && style.buttonLandscape]}
onPress={leaveCallHandler}
>
<CompassIcon
name='phone-hangup'
size={24}
style={[style.buttonIcon, isLandscape && style.buttonIconLandscape, style.hangUpIcon]}
/>
<FormattedText
id={'mobile.calls_leave'}
defaultMessage={'Leave'}
style={style.buttonText}
/>
</Pressable>
<AudioDeviceButton
pressableStyle={[style.button, isLandscape && style.buttonLandscape]}
iconStyle={[
style.buttonIcon,
isLandscape && style.buttonIconLandscape,
style.speakerphoneIcon,
currentCall.speakerphoneOn && style.buttonOn,
]}
buttonTextStyle={style.buttonText}
currentCall={currentCall}
/>
<Pressable
style={[style.button, isLandscape && style.buttonLandscape]}
onPress={toggleReactions}
>
<CompassIcon
name={'emoticon-happy-outline'}
size={24}
style={[style.buttonIcon, isLandscape && style.buttonIconLandscape, showReactions && style.buttonOn]}
/>
<FormattedText
id={'mobile.calls_react'}
defaultMessage={'React'}
style={style.buttonText}
/>
</Pressable>
<Pressable
style={[style.button, isLandscape && style.buttonLandscape]}
onPress={showOtherActions}
>
<CompassIcon
name='dots-horizontal'
size={24}
style={[style.buttonIcon, isLandscape && style.buttonIconLandscape]}
/>
<FormattedText
id={'mobile.calls_more'}
defaultMessage={'More'}
style={style.buttonText}
/>
</Pressable>
{isLandscape &&
{micPermissionsError && <PermissionErrorBar/>}
<View style={[style.buttonsContainer]}>
<View
style={[
style.buttons,
isLandscape && style.buttonsLandscape,
isLandscape && showReactions && style.buttonsLandscapeWithReactions,
isLandscape && !showControlsInLandscape && style.buttonsLandscapeNoControls,
]}
>
{showReactions &&
<ReactionBar raisedHand={myParticipant.raisedHand}/>
}
{!isLandscape &&
<Pressable
testID='mute-unmute'
style={[style.button, style.buttonLandscape]}
style={[style.mute, myParticipant.muted && style.muteMuted]}
onPress={muteUnmuteHandler}
disabled={!micPermissionsGranted}
>
<CompassIcon
<UnavailableIconWrapper
name={myParticipant.muted ? 'microphone-off' : 'microphone'}
size={24}
style={[
style.buttonIcon,
isLandscape && style.buttonIconLandscape,
style.muteIconLandscape,
myParticipant?.muted && style.muteIconLandscapeMuted,
]}
size={32}
unavailable={!micPermissionsGranted}
style={style.muteIcon}
/>
{myParticipant.muted ? UnmuteText : MuteText}
</Pressable>
}
<View style={[style.otherButtons, isLandscape && style.otherButtonsLandscape]}>
<Pressable
testID='leave'
style={[style.button, isLandscape && style.buttonLandscape]}
onPress={leaveCallHandler}
>
<CompassIcon
name='phone-hangup'
size={32}
style={[style.buttonIcon, isLandscape && style.buttonIconLandscape, style.hangUpIcon]}
/>
<FormattedText
id={'mobile.calls_leave'}
defaultMessage={'Leave'}
style={style.buttonText}
/>
</Pressable>
<AudioDeviceButton
pressableStyle={[style.button, isLandscape && style.buttonLandscape]}
iconStyle={[
style.buttonIcon,
isLandscape && style.buttonIconLandscape,
style.speakerphoneIcon,
currentCall.speakerphoneOn && style.buttonOn,
]}
buttonTextStyle={style.buttonText}
currentCall={currentCall}
/>
<Pressable
style={[style.button, isLandscape && style.buttonLandscape]}
onPress={toggleReactions}
>
<CompassIcon
name={'emoticon-happy-outline'}
size={32}
style={[style.buttonIcon, isLandscape && style.buttonIconLandscape, showReactions && style.buttonOn]}
/>
<FormattedText
id={'mobile.calls_react'}
defaultMessage={'React'}
style={style.buttonText}
/>
</Pressable>
{!isLandscape && isHost &&
<Pressable
style={[style.button, isLandscape && style.buttonLandscape]}
onPress={showOtherActions}
>
<CompassIcon
name='dots-horizontal'
size={32}
style={[style.buttonIcon, isLandscape && style.buttonIconLandscape]}
/>
<FormattedText
id={'mobile.calls_more'}
defaultMessage={'More'}
style={style.buttonText}
/>
</Pressable>
}
{isLandscape &&
<Pressable
testID='mute-unmute'
style={[style.button, style.buttonLandscape]}
onPress={muteUnmuteHandler}
>
<UnavailableIconWrapper
name={myParticipant.muted ? 'microphone-off' : 'microphone'}
size={32}
unavailable={!micPermissionsGranted}
style={[
style.buttonIcon,
isLandscape && style.buttonIconLandscape,
style.muteIconLandscape,
myParticipant?.muted && style.muteIconLandscapeMuted,
]}
errorContainerStyle={isLandscape && style.errorContainerLandscape}
/>
{myParticipant.muted ? UnmuteText : MuteText}
</Pressable>
}
{(isLandscape || !isHost) &&
<Pressable
style={[style.button, isLandscape && style.buttonLandscape]}
onPress={switchToThread}
>
<CompassIcon
name='message-text-outline'
size={32}
style={[style.buttonIcon, isLandscape && style.buttonIconLandscape]}
/>
<FormattedText
id={'mobile.calls_thread'}
defaultMessage={'Thread'}
style={style.buttonText}
/>
</Pressable>
}
{isLandscape && showStartRecording &&
<Pressable
style={[style.button, isLandscape && style.buttonLandscape]}
onPress={startRecording}
>
<CompassIcon
name='record-circle-outline'
size={32}
style={[style.buttonIcon, isLandscape && style.buttonIconLandscape]}
/>
<Text style={style.buttonText}>{recordOptionTitle}</Text>
</Pressable>
}
{isLandscape && showStopRecording &&
<Pressable
style={[style.button, isLandscape && style.buttonLandscape]}
onPress={stopRecording}
>
<CompassIcon
name='record-square-outline'
size={32}
style={[style.buttonIcon, isLandscape && style.buttonIconLandscape]}
/>
<Text style={style.buttonText}>{stopRecordingOptionTitle}</Text>
</Pressable>
}
</View>
</View>
</View>
</View>

View file

@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import {getHandsRaisedNames, makeCallsTheme} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {CallParticipant, CallsTheme} from '@calls/types/calls';
export type Props = {
raisedHands: CallParticipant[];
currentUserId: string;
teammateNameDisplay: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => ({
raisedHandBannerContainer: {
display: 'flex',
flexDirection: 'row',
flex: 1,
justifyContent: 'center',
},
raisedHandBanner: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 4,
paddingRight: 18,
paddingBottom: 4,
paddingLeft: 6,
marginRight: 6,
marginLeft: 4,
gap: 4,
borderRadius: 18,
backgroundColor: theme.sidebarText,
},
raisedHandIcon: {
color: theme.awayIndicator,
},
raisedHandName: {
...typography('Body', 100, 'SemiBold'),
color: theme.sidebarTeamBarBg,
},
raisedHandText: {
...typography(),
color: theme.sidebarTeamBarBg,
},
}));
export const RaisedHandBanner = ({raisedHands, currentUserId, teammateNameDisplay}: Props) => {
const intl = useIntl();
const theme = useTheme();
const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]);
const style = getStyleSheet(callsTheme);
if (raisedHands.length === 0) {
return <View style={style.raisedHandBannerContainer}/>;
}
const names = getHandsRaisedNames(raisedHands, currentUserId, intl.locale, teammateNameDisplay, intl);
return (
<View style={style.raisedHandBannerContainer}>
<View style={style.raisedHandBanner}>
<CompassIcon
name={'hand-right'}
size={16}
style={style.raisedHandIcon}
/>
<FormattedText
style={style.raisedHandText}
id='mobile.calls_raised_hand'
defaultMessage='<bold>{name} {num, plural, =0 {} other {+# more }}</bold>raised a hand'
values={{
name: names[0],
num: names.length - 1,
bold: (str: string) => <Text style={style.raisedHandName}>{str}</Text>,
}}
numberOfLines={1}
/>
</View>
</View>
);
};

View file

@ -136,6 +136,12 @@ export type ReactionStreamEmoji = {
literal?: string;
};
export type CallsTheme = Theme & {
callsBg: string;
callsBgRgb: string;
callsBadgeBg: string;
};
export type AudioDeviceInfoRaw = {
availableAudioDeviceList: string;
selectedAudioDevice: AudioDevice;

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls/lib/utils';
import {Alert} from 'react-native';
import {Post} from '@constants';
@ -8,26 +9,26 @@ import Calls from '@constants/calls';
import {isMinimumServerVersion} from '@utils/helpers';
import {displayUsername} from '@utils/user';
import type {CallParticipant} from '@calls/types/calls';
import type {CallParticipant, CallsTheme} from '@calls/types/calls';
import type {CallsConfig} from '@mattermost/calls/lib/types';
import type PostModel from '@typings/database/models/servers/post';
import type {IntlShape} from 'react-intl';
import type {RTCIceServer} from 'react-native-webrtc';
export function sortParticipants(teammateNameDisplay: string, participants?: Dictionary<CallParticipant>, presenterID?: string): CallParticipant[] {
export function sortParticipants(locale: string, teammateNameDisplay: string, participants?: Dictionary<CallParticipant>, presenterID?: string): CallParticipant[] {
if (!participants) {
return [];
}
const users = Object.values(participants);
return users.sort(sortByName(teammateNameDisplay)).sort(sortByState(presenterID));
return users.sort(sortByName(locale, teammateNameDisplay)).sort(sortByState(presenterID));
}
const sortByName = (teammateNameDisplay: string) => {
const sortByName = (locale: string, teammateNameDisplay: string) => {
return (a: CallParticipant, b: CallParticipant) => {
const nameA = displayUsername(a.userModel, teammateNameDisplay);
const nameB = displayUsername(b.userModel, teammateNameDisplay);
const nameA = displayUsername(a.userModel, locale, teammateNameDisplay);
const nameB = displayUsername(b.userModel, locale, teammateNameDisplay);
return nameA.localeCompare(nameB);
};
};
@ -58,6 +59,19 @@ const sortByState = (presenterID?: string) => {
};
};
export function getHandsRaised(participants: Dictionary<CallParticipant>) {
return Object.values(participants).filter((p) => p.raisedHand);
}
export function getHandsRaisedNames(participants: CallParticipant[], currentUserId: string, locale: string, teammateNameDisplay: string, intl: IntlShape) {
return participants.sort((a, b) => a.raisedHand - b.raisedHand).map((p) => {
if (p.id === currentUserId) {
return intl.formatMessage({id: 'mobile.calls_you_2', defaultMessage: 'You'});
}
return displayUsername(p.userModel, locale, teammateNameDisplay);
});
}
export function isSupportedServerCalls(serverVersion?: string) {
if (serverVersion) {
return isMinimumServerVersion(
@ -126,3 +140,14 @@ export function getICEServersConfigs(config: CallsConfig): RTCIceServer[] {
return [];
}
export function makeCallsTheme(theme: Theme): CallsTheme {
const {baseColorRGB, badgeBgRGB} = makeCallsBaseAndBadgeRGB(theme.sidebarBg);
const newTheme = {...theme} as CallsTheme;
newTheme.callsBg = rgbToCSS(baseColorRGB);
newTheme.callsBgRgb = `${baseColorRGB.r},${baseColorRGB.g},${baseColorRGB.b}`;
newTheme.callsBadgeBg = rgbToCSS(badgeBgRGB);
return newTheme;
}

View file

@ -5,9 +5,8 @@ import React, {useCallback, useEffect, useRef, useState} from 'react';
import {type LayoutChangeEvent, StyleSheet, View} from 'react-native';
import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import CurrentCallBar from '@calls/components/current_call_bar';
import FloatingCallContainer from '@calls/components/floating_call_container';
import JoinCallBanner from '@calls/components/join_call_banner';
import {RoundedHeaderCalls} from '@calls/components/join_call_banner/rounded_header_calls';
import FreezeScreen from '@components/freeze_screen';
import PostDraft from '@components/post_draft';
import {Screens} from '@constants';
@ -28,7 +27,6 @@ import type {AvailableScreens} from '@typings/screens/navigation';
import type {KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view';
type ChannelProps = {
serverUrl: string;
channelId: string;
componentId?: AvailableScreens;
isCallInCurrentChannel: boolean;
@ -48,7 +46,6 @@ const styles = StyleSheet.create({
});
const Channel = ({
serverUrl,
channelId,
componentId,
isCallInCurrentChannel,
@ -97,21 +94,8 @@ const Channel = ({
setContainerHeight(e.nativeEvent.layout.height);
}, []);
let callsComponents: JSX.Element | null = null;
const showJoinCallBanner = isCallInCurrentChannel && !isInCurrentChannelCall;
if (showJoinCallBanner || isInACall) {
callsComponents = (
<FloatingCallContainer>
{showJoinCallBanner &&
<JoinCallBanner
serverUrl={serverUrl}
channelId={channelId}
/>
}
{isInACall && <CurrentCallBar/>}
</FloatingCallContainer>
);
}
const renderCallsComponents = showJoinCallBanner || isInACall;
return (
<FreezeScreen>
@ -128,6 +112,7 @@ const Channel = ({
callsEnabledInChannel={isCallsEnabledInChannel}
isTabletView={isTabletView}
/>
{showJoinCallBanner && <RoundedHeaderCalls/>}
{shouldRender &&
<>
<View style={[styles.flex, {marginTop}]}>
@ -150,7 +135,13 @@ const Channel = ({
/>
</>
}
{callsComponents}
{renderCallsComponents &&
<FloatingCallContainer
channelId={channelId}
showJoinCallBanner={showJoinCallBanner}
isInACall={isInACall}
/>
}
</SafeAreaView>
</FreezeScreen>
);

View file

@ -3,8 +3,7 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {combineLatest, distinctUntilChanged, of as of$, switchMap} from 'rxjs';
import {observeIsCallsEnabledInChannel} from '@calls/observers';
import {observeChannelsWithCalls, observeCurrentCall} from '@calls/state';
@ -21,6 +20,7 @@ type EnhanceProps = WithDatabaseArgs & {
const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => {
const channelId = observeCurrentChannelId(database);
const isCallInCurrentChannel = combineLatest([channelId, observeChannelsWithCalls(serverUrl)]).pipe(
switchMap(([id, calls]) => of$(Boolean(calls[id]))),
distinctUntilChanged(),
@ -38,14 +38,13 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => {
switchMap(([id, ccId]) => of$(id === ccId)),
distinctUntilChanged(),
);
const isCallsEnabledInChannel = observeIsCallsEnabledInChannel(database, serverUrl, channelId);
return {
channelId,
isCallInCurrentChannel,
isInACall,
isInCurrentChannelCall,
isCallsEnabledInChannel,
isCallsEnabledInChannel: observeIsCallsEnabledInChannel(database, serverUrl, channelId),
};
});

View file

@ -3,10 +3,10 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {distinctUntilChanged, switchMap, combineLatest, of as of$} from 'rxjs';
import {observeCurrentCall} from '@calls/state';
import {observeChannelsWithCalls, observeCurrentCall} from '@calls/state';
import {withServerUrl} from '@context/server';
import {observePost} from '@queries/servers/post';
import {observeIsCRTEnabled} from '@queries/servers/thread';
@ -14,17 +14,43 @@ import Thread from './thread';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables(['rootId'], ({database, rootId}: WithDatabaseArgs & {rootId: string}) => {
const isInACall = observeCurrentCall().pipe(
type EnhanceProps = WithDatabaseArgs & {
serverUrl: string;
rootId: string;
}
const enhanced = withObservables(['rootId'], ({database, serverUrl, rootId}: EnhanceProps) => {
const rootPost = observePost(database, rootId);
const channelId = rootPost.pipe(
switchMap((r) => of$(r?.channelId || '')),
distinctUntilChanged(),
);
const isCallInCurrentChannel = combineLatest([channelId, observeChannelsWithCalls(serverUrl)]).pipe(
switchMap(([id, calls]) => of$(Boolean(calls[id]))),
distinctUntilChanged(),
);
const currentCall = observeCurrentCall();
const ccChannelId = currentCall.pipe(
switchMap((call) => of$(call?.channelId)),
distinctUntilChanged(),
);
const isInACall = currentCall.pipe(
switchMap((call) => of$(Boolean(call?.connected))),
distinctUntilChanged(),
);
const isInCurrentChannelCall = combineLatest([channelId, ccChannelId]).pipe(
switchMap(([id, ccId]) => of$(id === ccId)),
distinctUntilChanged(),
);
return {
isCRTEnabled: observeIsCRTEnabled(database),
isCallInCurrentChannel,
isInACall,
rootPost: observePost(database, rootId),
isInCurrentChannelCall,
rootPost,
};
});
export default withDatabase(enhanced(Thread));
export default withDatabase(withServerUrl(enhanced(Thread)));

View file

@ -5,8 +5,8 @@ import React, {useCallback, useEffect, useRef, useState} from 'react';
import {type LayoutChangeEvent, StyleSheet, View} from 'react-native';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import CurrentCallBar from '@calls/components/current_call_bar';
import FloatingCallContainer from '@calls/components/floating_call_container';
import {RoundedHeaderCalls} from '@calls/components/join_call_banner/rounded_header_calls';
import FreezeScreen from '@components/freeze_screen';
import PostDraft from '@components/post_draft';
import RoundedHeaderContext from '@components/rounded_header_context';
@ -27,7 +27,9 @@ import type {KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view'
type ThreadProps = {
componentId: AvailableScreens;
isCRTEnabled: boolean;
isCallInCurrentChannel: boolean;
isInACall: boolean;
isInCurrentChannelCall: boolean;
rootId: string;
rootPost?: PostModel;
};
@ -39,7 +41,15 @@ const styles = StyleSheet.create({
flex: {flex: 1},
});
const Thread = ({componentId, isCRTEnabled, rootId, rootPost, isInACall}: ThreadProps) => {
const Thread = ({
componentId,
isCRTEnabled,
rootId,
rootPost,
isCallInCurrentChannel,
isInACall,
isInCurrentChannelCall,
}: ThreadProps) => {
const postDraftRef = useRef<KeyboardTrackingViewRef>(null);
const [containerHeight, setContainerHeight] = useState(0);
@ -84,6 +94,9 @@ const Thread = ({componentId, isCRTEnabled, rootId, rootPost, isInACall}: Thread
setContainerHeight(e.nativeEvent.layout.height);
}, []);
const showJoinCallBanner = isCallInCurrentChannel && !isInCurrentChannelCall;
const renderCallsComponents = showJoinCallBanner || isInACall;
return (
<FreezeScreen>
<SafeAreaView
@ -94,6 +107,7 @@ const Thread = ({componentId, isCRTEnabled, rootId, rootPost, isInACall}: Thread
onLayout={onLayout}
>
<RoundedHeaderContext/>
{showJoinCallBanner && <RoundedHeaderCalls threadScreen={true}/>}
{Boolean(rootPost) &&
<>
<View style={styles.flex}>
@ -114,10 +128,13 @@ const Thread = ({componentId, isCRTEnabled, rootId, rootPost, isInACall}: Thread
/>
</>
}
{isInACall &&
<FloatingCallContainer threadScreen={true}>
<CurrentCallBar threadScreen={true}/>
</FloatingCallContainer>
{renderCallsComponents &&
<FloatingCallContainer
channelId={rootPost!.channelId}
showJoinCallBanner={showJoinCallBanner}
isInACall={isInACall}
threadScreen={true}
/>
}
</SafeAreaView>
</FreezeScreen>

View file

@ -452,7 +452,7 @@
"mobile.calls_mic_error": "To participate, open Settings to grant Mattermost access to your microphone.",
"mobile.calls_more": "More",
"mobile.calls_mute": "Mute",
"mobile.calls_name_is_talking": "{name} is talking",
"mobile.calls_name_is_talking_postfix": "is talking...",
"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.",
@ -467,6 +467,7 @@
"mobile.calls_participant_rec_title": "Recording is in progress",
"mobile.calls_phone": "Phone",
"mobile.calls_raise_hand": "Raise hand",
"mobile.calls_raised_hand": "<bold>{name} {num, plural, =0 {} other {+# more }}</bold>raised a hand",
"mobile.calls_react": "React",
"mobile.calls_rec": "rec",
"mobile.calls_record": "Record",
@ -481,9 +482,12 @@
"mobile.calls_start_call": "Start Call",
"mobile.calls_start_call_exists": "A call is already ongoing in the channel.",
"mobile.calls_stop_recording": "Stop Recording",
"mobile.calls_tablet": "Tablet",
"mobile.calls_thread": "Thread",
"mobile.calls_unmute": "Unmute",
"mobile.calls_viewing_screen": "You are viewing {name}'s screen",
"mobile.calls_you": "(you)",
"mobile.calls_you_2": "You",
"mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} read and write access to your camera.",
"mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera",
"mobile.camera_type.title": "Camera options",