From f8f6839945c47ad0000a32c0a01129688cb085de Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Thu, 4 May 2023 14:17:17 -0400 Subject: [PATCH] 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 --- app/components/user_avatars_stack/index.tsx | 21 +- app/constants/view.ts | 2 +- .../components/audio_device_button.ios.tsx | 2 +- .../calls/components/audio_device_button.tsx | 2 +- app/products/calls/components/call_avatar.tsx | 195 +++--- .../calls/components/call_duration.tsx | 6 +- app/products/calls/components/calls_badge.tsx | 2 + .../calls_custom_message.tsx | 43 +- .../channel_info_enable_calls/index.tsx | 2 +- .../channel_info_start_button.tsx | 2 +- .../current_call_bar/current_call_bar.tsx | 215 ++++-- app/products/calls/components/emoji_list.tsx | 11 +- .../components/floating_call_container.tsx | 18 +- .../join_call_banner/join_call_banner.tsx | 48 +- .../join_call_banner/rounded_header_calls.tsx | 45 ++ .../calls/components/reaction_bar.tsx | 83 ++- .../components/unavailable_icon_wrapper.tsx | 17 +- .../calls/screens/call_screen/call_screen.tsx | 615 +++++++++++------- .../call_screen/raised_hand_banner.tsx | 90 +++ app/products/calls/types/calls.ts | 6 + app/products/calls/utils.ts | 37 +- app/screens/channel/channel.tsx | 29 +- app/screens/channel/index.tsx | 7 +- app/screens/thread/index.tsx | 40 +- app/screens/thread/thread.tsx | 29 +- assets/base/i18n/en.json | 6 +- 26 files changed, 1036 insertions(+), 537 deletions(-) create mode 100644 app/products/calls/components/join_call_banner/rounded_header_calls.tsx create mode 100644 app/products/calls/screens/call_screen/raised_hand_banner.tsx diff --git a/app/components/user_avatars_stack/index.tsx b/app/components/user_avatars_stack/index.tsx index c92175e57..86734f059 100644 --- a/app/components/user_avatars_stack/index.tsx +++ b/app/components/user_avatars_stack/index.tsx @@ -31,6 +31,7 @@ type Props = { users: UserModel[]; breakAt?: number; style?: StyleProp; + 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) => ( ))} {Boolean(overflowUsersCount) && ( - - - + + + {'+' + overflowUsersCount.toString()} diff --git a/app/constants/view.ts b/app/constants/view.ts index 8f2a0dca5..057fc53a7 100644 --- a/app/constants/view.ts +++ b/app/constants/view.ts @@ -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; diff --git a/app/products/calls/components/audio_device_button.ios.tsx b/app/products/calls/components/audio_device_button.ios.tsx index 40f6936bf..2a8346f94 100644 --- a/app/products/calls/components/audio_device_button.ios.tsx +++ b/app/products/calls/components/audio_device_button.ios.tsx @@ -32,7 +32,7 @@ export const AudioDeviceButton = ({pressableStyle, iconStyle, buttonTextStyle, c > {speakerLabel} diff --git a/app/products/calls/components/audio_device_button.tsx b/app/products/calls/components/audio_device_button.tsx index 1348125de..47c339bec 100644 --- a/app/products/calls/components/audio_device_button.tsx +++ b/app/products/calls/components/audio_device_button.tsx @@ -115,7 +115,7 @@ export const AudioDeviceButton = ({pressableStyle, iconStyle, buttonTextStyle, c > {label} diff --git a/app/products/calls/components/call_avatar.tsx b/app/products/calls/components/call_avatar.tsx index 10015d773..e29c09534 100644 --- a/app/products/calls/components/call_avatar.tsx +++ b/app/products/calls/components/call_avatar.tsx @@ -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 = ( - + + + ); } else if (raisedHand) { topRightIcon = ( - - {'✋'} - + + + ); } // An emoji will override the top right indicator. if (reaction) { topRightIcon = ( - - + + + + ); } @@ -162,14 +200,15 @@ const CallAvatar = ({userModel, volume, serverUrl, sharingScreen, size, muted, r const profile = userModel ? ( ) : ( ); @@ -178,11 +217,13 @@ const CallAvatar = ({userModel, volume, serverUrl, sharingScreen, size, muted, r {profile} { muted !== undefined && - + + + } {topRightIcon} diff --git a/app/products/calls/components/call_duration.tsx b/app/products/calls/components/call_duration.tsx index 7cb64336f..b68cc3b49 100644 --- a/app/products/calls/components/call_duration.tsx +++ b/app/products/calls/components/call_duration.tsx @@ -50,7 +50,11 @@ const CallDuration = ({value, style, updateIntervalInSeconds}: CallDurationProps }, [updateIntervalInSeconds]); return ( - + {formattedTime} ); diff --git a/app/products/calls/components/calls_badge.tsx b/app/products/calls/components/calls_badge.tsx index 58ec37c6c..21d1970ef 100644 --- a/app/products/calls/components/calls_badge.tsx +++ b/app/products/calls/components/calls_badge.tsx @@ -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', diff --git a/app/products/calls/components/calls_custom_message/calls_custom_message.tsx b/app/products/calls/components/calls_custom_message/calls_custom_message.tsx index e651dcff4..d2d50ee7c 100644 --- a/app/products/calls/components/calls_custom_message/calls_custom_message.tsx +++ b/app/products/calls/components/calls_custom_message/calls_custom_message.tsx @@ -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 = ({ @@ -190,7 +191,7 @@ export const CallsCustomMessage = ({ @@ -212,8 +213,8 @@ export const CallsCustomMessage = ({ onPress={joinHandler} > { diff --git a/app/products/calls/components/channel_info_enable_calls/index.tsx b/app/products/calls/components/channel_info_enable_calls/index.tsx index 8827e000a..ac3883f58 100644 --- a/app/products/calls/components/channel_info_enable_calls/index.tsx +++ b/app/products/calls/components/channel_info_enable_calls/index.tsx @@ -32,7 +32,7 @@ const ChannelInfoEnableCalls = ({channelId, enabled}: Props) => { diff --git a/app/products/calls/components/channel_info_start/channel_info_start_button.tsx b/app/products/calls/components/channel_info_start/channel_info_start_button.tsx index 0b699eac1..26cd872c9 100644 --- a/app/products/calls/components/channel_info_start/channel_info_start_button.tsx +++ b/app/products/calls/components/channel_info_start/channel_info_start_button.tsx @@ -54,7 +54,7 @@ const ChannelInfoStartButton = ({ { +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 = ( + + {formatMessage({ + id: 'mobile.calls_noone_talking', + defaultMessage: 'No one is talking', + })} + ); if (speaker) { - talkingMessage = formatMessage({ - id: 'mobile.calls_name_is_talking', - defaultMessage: '{name} is talking', - }, {name: displayUsername(userModelsDict[speaker], intl.locale, teammateNameDisplay)}); + talkingMessage = ( + + {displayUsername(userModelsDict[speaker], intl.locale, teammateNameDisplay)} + {' '} + { + formatMessage({ + id: 'mobile.calls_name_is_talking_postfix', + defaultMessage: 'is talking...', + })} + + ); } const muteUnmute = () => { @@ -166,39 +222,56 @@ const CurrentCallBar = ({ return ( <> - - - - {talkingMessage} - {`~${displayName}`} + + + - - - - - - - + + {talkingMessage} + + {`~${displayName}`} + {' • '} + + + + + + + + + + + + + {micPermissionsError && } diff --git a/app/products/calls/components/emoji_list.tsx b/app/products/calls/components/emoji_list.tsx index 05e518946..7d5a9543d 100644 --- a/app/products/calls/components/emoji_list.tsx +++ b/app/products/calls/components/emoji_list.tsx @@ -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 ( @@ -58,7 +63,7 @@ const EmojiList = ({reactionStream}: Props) => { diff --git a/app/products/calls/components/floating_call_container.tsx b/app/products/calls/components/floating_call_container.tsx index 2d38ea845..40f28f76a 100644 --- a/app/products/calls/components/floating_call_container.tsx +++ b/app/products/calls/components/floating_call_container.tsx @@ -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 ( - {props.children} + {showJoinCallBanner && + + } + {isInACall && } ); }; diff --git a/app/products/calls/components/join_call_banner/join_call_banner.tsx b/app/products/calls/components/join_call_banner/join_call_banner.tsx index 77928e301..7f24ecdf7 100644 --- a/app/products/calls/components/join_call_banner/join_call_banner.tsx +++ b/app/products/calls/components/join_call_banner/join_call_banner.tsx @@ -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 = ({ > )} - - - + ); diff --git a/app/products/calls/components/join_call_banner/rounded_header_calls.tsx b/app/products/calls/components/join_call_banner/rounded_header_calls.tsx new file mode 100644 index 000000000..54671ce88 --- /dev/null +++ b/app/products/calls/components/join_call_banner/rounded_header_calls.tsx @@ -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 ( + + + + ); +}; diff --git a/app/products/calls/components/reaction_bar.tsx b/app/products/calls/components/reaction_bar.tsx index 90b6c2acb..c295c8dae 100644 --- a/app/products/calls/components/reaction_bar.tsx +++ b/app/products/calls/components/reaction_bar.tsx @@ -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) => { ); const RaiseHandText = ( ); const toggleRaiseHand = useCallback(() => { @@ -87,28 +100,30 @@ const ReactionBar = ({raisedHand}: Props) => { }, [raisedHand]); return ( - - - - {raisedHand ? LowerHandText : RaiseHandText} - - { - predefinedReactions.map(([name, unified]) => ( - sendReaction({name, unified})} + + + + - )) - } + {raisedHand ? LowerHandText : RaiseHandText} + + { + predefinedReactions.map(([name, unified]) => ( + sendReaction({name, unified})} + /> + )) + } + ); }; diff --git a/app/products/calls/components/unavailable_icon_wrapper.tsx b/app/products/calls/components/unavailable_icon_wrapper.tsx index fed180554..c64c77312 100644 --- a/app/products/calls/components/unavailable_icon_wrapper.tsx +++ b/app/products/calls/components/unavailable_icon_wrapper.tsx @@ -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; unavailable: boolean; + errorContainerStyle?: StyleProp; } 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 && ({ +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(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 ( { - isHost && EnableRecordings && !(waitingForRecording || recording) && + showStartRecording && } { - isHost && EnableRecordings && (waitingForRecording || recording) && + showStopRecording && 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 = ( - + - {participants.map((user) => { - return ( - - - - {displayUsername(user.userModel, intl.locale, teammateNameDisplay)} - {user.id === myParticipant.id && - ` ${intl.formatMessage({id: 'mobile.calls_you', defaultMessage: '(you)'})}` - } - - {user.id === currentCall.hostId && } - - ); - })} - - + + {participants.map((user) => { + return ( + + + + {displayUsername(user.userModel, intl.locale, teammateNameDisplay)} + {user.id === myParticipant.id && + ` ${intl.formatMessage({id: 'mobile.calls_you', defaultMessage: '(you)'})}` + } + + {user.id === currentCall.hostId && } + + ); + })} + + + ); } @@ -586,141 +649,205 @@ const CallScreen = ({ style={[style.buttonText, !micPermissionsGranted && style.unavailableText]} />); + const header = ( + + {waitingForRecording && } + {recording && } + + + popTopScreen()} + style={style.collapseIconContainer} + > + + + + ); + return ( + - - {waitingForRecording && } - {recording && } - - popTopScreen()}> - - - + {!isLandscape && header} + {!isLandscape && } {usersList} {screenShareView} - {micPermissionsError && } - {!isLandscape && + {isLandscape && header} + {!isLandscape && currentCall.reactionStream.length > 0 && } - - {showReactions && - - } - {!isLandscape && - - - {myParticipant.muted ? UnmuteText : MuteText} - - } - - - - - - - - - - - - - - - {isLandscape && + {micPermissionsError && } + + + {showReactions && + + } + {!isLandscape && - {myParticipant.muted ? UnmuteText : MuteText} } + + + + + + + + + + + {!isLandscape && isHost && + + + + + } + {isLandscape && + + + {myParticipant.muted ? UnmuteText : MuteText} + + } + {(isLandscape || !isHost) && + + + + + } + {isLandscape && showStartRecording && + + + {recordOptionTitle} + + } + {isLandscape && showStopRecording && + + + {stopRecordingOptionTitle} + + } + diff --git a/app/products/calls/screens/call_screen/raised_hand_banner.tsx b/app/products/calls/screens/call_screen/raised_hand_banner.tsx new file mode 100644 index 000000000..fdbc5e3f8 --- /dev/null +++ b/app/products/calls/screens/call_screen/raised_hand_banner.tsx @@ -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 ; + } + + const names = getHandsRaisedNames(raisedHands, currentUserId, intl.locale, teammateNameDisplay, intl); + + return ( + + + + {str}, + }} + numberOfLines={1} + /> + + + ); +}; diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 445cfa609..6225b5579 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -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; diff --git a/app/products/calls/utils.ts b/app/products/calls/utils.ts index d52b31581..c002ab820 100644 --- a/app/products/calls/utils.ts +++ b/app/products/calls/utils.ts @@ -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, presenterID?: string): CallParticipant[] { +export function sortParticipants(locale: string, teammateNameDisplay: string, participants?: Dictionary, 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) { + 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; +} diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index ce50e5445..69140bafc 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -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 = ( - - {showJoinCallBanner && - - } - {isInACall && } - - ); - } + const renderCallsComponents = showJoinCallBanner || isInACall; return ( @@ -128,6 +112,7 @@ const Channel = ({ callsEnabledInChannel={isCallsEnabledInChannel} isTabletView={isTabletView} /> + {showJoinCallBanner && } {shouldRender && <> @@ -150,7 +135,13 @@ const Channel = ({ /> } - {callsComponents} + {renderCallsComponents && + + } ); diff --git a/app/screens/channel/index.tsx b/app/screens/channel/index.tsx index a936030b4..932149181 100644 --- a/app/screens/channel/index.tsx +++ b/app/screens/channel/index.tsx @@ -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), }; }); diff --git a/app/screens/thread/index.tsx b/app/screens/thread/index.tsx index 24a23491a..08dada76a 100644 --- a/app/screens/thread/index.tsx +++ b/app/screens/thread/index.tsx @@ -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))); diff --git a/app/screens/thread/thread.tsx b/app/screens/thread/thread.tsx index 60afd190c..10a361243 100644 --- a/app/screens/thread/thread.tsx +++ b/app/screens/thread/thread.tsx @@ -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(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 ( + {showJoinCallBanner && } {Boolean(rootPost) && <> @@ -114,10 +128,13 @@ const Thread = ({componentId, isCRTEnabled, rootId, rootPost, isInACall}: Thread /> } - {isInACall && - - - + {renderCallsComponents && + } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 07aab0ba3..75b2b9723 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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": "{name} {num, plural, =0 {} other {+# more }}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",