From 1774445715ab01e6066c5d8625f38bde247e2115 Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Mon, 22 Apr 2024 11:01:18 -0400 Subject: [PATCH] [MM-57706] Calls: Participants list for call screen (#7895) * Add participants bottom sheet to call screen * i18n * fix ruby-version * use tag instead of host_badge * extract Participant, use FlatList * fix for android * android participants list close button --- app/constants/screens.ts | 3 + app/products/calls/observers/index.ts | 9 +- .../calls/screens/call_screen/call_screen.tsx | 25 +++ .../calls/screens/call_screen/index.ts | 13 +- .../calls/screens/participants_list/index.ts | 24 +++ .../screens/participants_list/participant.tsx | 159 ++++++++++++++++++ .../participants_list/participants_list.tsx | 102 +++++++++++ .../calls/screens/participants_list/pill.tsx | 51 ++++++ app/screens/index.tsx | 3 + assets/base/i18n/en.json | 2 + 10 files changed, 379 insertions(+), 12 deletions(-) create mode 100644 app/products/calls/screens/participants_list/index.ts create mode 100644 app/products/calls/screens/participants_list/participant.tsx create mode 100644 app/products/calls/screens/participants_list/participants_list.tsx create mode 100644 app/products/calls/screens/participants_list/pill.tsx diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 2c0014171..4acce8fd1 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -7,6 +7,7 @@ export const APPS_FORM = 'AppForm'; export const BOTTOM_SHEET = 'BottomSheet'; export const BROWSE_CHANNELS = 'BrowseChannels'; export const CALL = 'Call'; +export const CALL_PARTICIPANTS = 'CallParticipants'; export const CHANNEL = 'Channel'; export const CHANNEL_ADD_MEMBERS = 'ChannelAddMembers'; export const CHANNEL_FILES = 'ChannelFiles'; @@ -80,6 +81,7 @@ export default { BOTTOM_SHEET, BROWSE_CHANNELS, CALL, + CALL_PARTICIPANTS, CHANNEL, CHANNEL_ADD_MEMBERS, CHANNEL_FILES, @@ -178,6 +180,7 @@ export const SCREENS_AS_BOTTOM_SHEET = new Set([ THREAD_OPTIONS, REACTIONS, USER_PROFILE, + CALL_PARTICIPANTS, ]); export const NOT_READY = [ diff --git a/app/products/calls/observers/index.ts b/app/products/calls/observers/index.ts index a4a1a616a..b8bcce385 100644 --- a/app/products/calls/observers/index.ts +++ b/app/products/calls/observers/index.ts @@ -77,13 +77,18 @@ export const observeIsCallLimitRestricted = (database: Database, serverUrl: stri ) as Observable; }; -export const observeCurrentSessionsDict = () => { +export const observeCallDatabase = () => { const currentCall = observeCurrentCall(); - const database = currentCall.pipe( + return currentCall.pipe( switchMap((call) => of$(call ? call.serverUrl : '')), distinctUntilChanged(), switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)), ); +}; + +export const observeCurrentSessionsDict = () => { + const currentCall = observeCurrentCall(); + const database = observeCallDatabase(); return combineLatest([database, currentCall]).pipe( switchMap(([db, call]) => (db && call ? queryUsersById(db, userIds(Object.values(call.sessions))).observeWithColumns(['nickname', 'username', 'first_name', 'last_name', 'last_picture_update']) : of$([])).pipe( diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index f914ea786..50e90921b 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -60,6 +60,7 @@ import { dismissAllModalsAndPopToScreen, dismissBottomSheet, goToScreen, + openAsBottomSheet, popTopScreen, setScreensOrientation, } from '@screens/navigation'; @@ -486,6 +487,15 @@ const CallScreen = ({ const showStopRecording = isHost && EnableRecordings && (waitingForRecording || recording); const ccAvailable = Boolean((currentCall?.capState?.start_at || 0) > (currentCall?.capState?.end_at || 0)); + const openParticipantsList = useCallback(async () => { + const screen = Screens.CALL_PARTICIPANTS; + const title = intl.formatMessage({id: 'mobile.calls_participants', defaultMessage: 'Participants'}); + const closeButtonId = 'close-call-participants'; + + Keyboard.dismiss(); + openAsBottomSheet({screen, title, theme, closeButtonId}); + }, [theme]); + const showOtherActions = useCallback(async () => { const renderContent = () => { return ( @@ -821,6 +831,21 @@ const CallScreen = ({ style={style.buttonText} /> + + + + {!isLandscape && (isHost || ccAvailable) && { - const currentCall = observeCurrentCall(); - const database = currentCall.pipe( - switchMap((call) => of$(call ? call.serverUrl : '')), - distinctUntilChanged(), - switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)), - ); const micPermissionsGranted = observeGlobalCallsState().pipe( switchMap((gs) => of$(gs.micPermissionsGranted)), distinctUntilChanged(), ); - const teammateNameDisplay = database.pipe( + const teammateNameDisplay = observeCallDatabase().pipe( switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))), distinctUntilChanged(), ); return { - currentCall, + currentCall: observeCurrentCall(), sessionsDict: observeCurrentSessionsDict(), micPermissionsGranted, teammateNameDisplay, diff --git a/app/products/calls/screens/participants_list/index.ts b/app/products/calls/screens/participants_list/index.ts new file mode 100644 index 000000000..2e881e8c7 --- /dev/null +++ b/app/products/calls/screens/participants_list/index.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withObservables} from '@nozbe/watermelondb/react'; +import {of as of$} from 'rxjs'; +import {distinctUntilChanged, switchMap} from 'rxjs/operators'; + +import {observeCallDatabase, observeCurrentSessionsDict} from '@calls/observers'; +import {ParticipantsList} from '@calls/screens/participants_list/participants_list'; +import {observeTeammateNameDisplay} from '@queries/servers/user'; + +const enhanced = withObservables([], () => { + const teammateNameDisplay = observeCallDatabase().pipe( + switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))), + distinctUntilChanged(), + ); + + return { + sessionsDict: observeCurrentSessionsDict(), + teammateNameDisplay, + }; +}); + +export default enhanced(ParticipantsList); diff --git a/app/products/calls/screens/participants_list/participant.tsx b/app/products/calls/screens/participants_list/participant.tsx new file mode 100644 index 000000000..2525c3f0a --- /dev/null +++ b/app/products/calls/screens/participants_list/participant.tsx @@ -0,0 +1,159 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {Platform, Pressable, Text, View} from 'react-native'; + +import {useCurrentCall} from '@calls/state'; +import CompassIcon from '@components/compass_icon'; +import Emoji from '@components/emoji'; +import ProfilePicture from '@components/profile_picture'; +import Tag from '@components/tag'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; +import {displayUsername} from '@utils/user'; + +import type {CallSession} from '@calls/types/calls'; + +const PROFILE_SIZE = 32; + +type Props = { + sess: CallSession; + teammateNameDisplay: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + rowContainer: { + flexDirection: 'row', + paddingTop: 8, + paddingBottom: 8, + gap: 16, + alignItems: 'center', + }, + row: { + flexDirection: 'row', + flex: 1, + gap: 8, + }, + picture: { + borderRadius: PROFILE_SIZE / 2, + height: PROFILE_SIZE, + width: PROFILE_SIZE, + }, + name: { + ...typography('Body', 200, 'SemiBold'), + color: theme.centerChannelColor, + flex: 1, + }, + you: { + ...typography('Body', 200, 'SemiBold'), + color: changeOpacity(theme.centerChannelColor, 0.56), + }, + profileIcon: { + color: changeOpacity(theme.buttonColor, 0.56), + }, + icons: { + flexDirection: 'row', + gap: 16, + }, + muteIcon: { + color: changeOpacity(theme.centerChannelColor, 0.40), + }, + unmutedIcon: { + color: changeOpacity(theme.centerChannelColor, 0.56), + }, + hostTag: { + paddingVertical: 4, + }, + raiseHandIcon: { + color: theme.awayIndicator, + }, + screenSharingIcon: { + color: theme.dndIndicator, + }, +})); + +export const Participant = ({sess, teammateNameDisplay}: Props) => { + const intl = useIntl(); + const currentCall = useCurrentCall(); + const theme = useTheme(); + + const styles = getStyleSheet(theme); + + if (!currentCall) { + return null; + } + + return ( + + {sess.userModel ? ( + + ) : ( + + )} + + + {displayUsername(sess.userModel, intl.locale, teammateNameDisplay)} + + {sess.sessionId === currentCall.mySessionId && + + {intl.formatMessage({id: 'mobile.calls_you', defaultMessage: '(you)'})} + + } + {sess.userId === currentCall.hostId && + + } + + + {sess.reaction?.emoji !== undefined && + + } + {sess.raisedHand !== 0 && + + } + {sess.sessionId === currentCall.screenOn && + + } + + + + ); +}; diff --git a/app/products/calls/screens/participants_list/participants_list.tsx b/app/products/calls/screens/participants_list/participants_list.tsx new file mode 100644 index 000000000..824706524 --- /dev/null +++ b/app/products/calls/screens/participants_list/participants_list.tsx @@ -0,0 +1,102 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {BottomSheetFlatList} from '@gorhom/bottom-sheet'; +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {type ListRenderItemInfo, useWindowDimensions, View} from 'react-native'; +import {FlatList} from 'react-native-gesture-handler'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {Participant} from '@calls/screens/participants_list/participant'; +import Pill from '@calls/screens/participants_list/pill'; +import {sortSessions} from '@calls/utils'; +import FormattedText from '@components/formatted_text'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import BottomSheet from '@screens/bottom_sheet'; +import {bottomSheetSnapPoint} from '@utils/helpers'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type {CallSession} from '@calls/types/calls'; + +const ROW_HEIGHT = 48; +const HEADER_HEIGHT = 62; +const MIN_ROWS = 5; + +type Props = { + closeButtonId: string; + sessionsDict: Dictionary; + teammateNameDisplay: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + header: { + paddingBottom: 12, + flexDirection: 'row', + gap: 8, + alignItems: 'center', + }, + headerText: { + color: theme.centerChannelColor, + ...typography('Heading', 600, 'SemiBold'), + }, +})); + +export const ParticipantsList = ({closeButtonId, sessionsDict, teammateNameDisplay}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const {bottom} = useSafeAreaInsets(); + const {height} = useWindowDimensions(); + const isTablet = useIsTablet(); + const List = useMemo(() => (isTablet ? FlatList : BottomSheetFlatList), [isTablet]); + const styles = getStyleSheet(theme); + + const sessions = useMemo(() => sortSessions(intl.locale, teammateNameDisplay, sessionsDict), [sessionsDict]); + const snapPoint1 = bottomSheetSnapPoint(Math.min(sessions.length, MIN_ROWS), ROW_HEIGHT, bottom) + HEADER_HEIGHT; + const snapPoint2 = height * 0.8; + const snapPoints = [1, Math.min(snapPoint1, snapPoint2)]; + if (sessions.length > MIN_ROWS && snapPoint1 < snapPoint2) { + snapPoints.push(snapPoint2); + } + + const renderItem = useCallback(({item}: ListRenderItemInfo) => ( + + ), [teammateNameDisplay]); + + const renderContent = () => { + return ( + <> + + + + + + + + ); + }; + + return ( + + ); +}; diff --git a/app/products/calls/screens/participants_list/pill.tsx b/app/products/calls/screens/participants_list/pill.tsx new file mode 100644 index 000000000..30b1963bf --- /dev/null +++ b/app/products/calls/screens/participants_list/pill.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {StyleSheet, Text, View} from 'react-native'; + +import {useTheme} from '@context/theme'; +import {changeOpacity} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + text: string | number; +} + +const getStyleSheet = ({theme}: { theme: Theme }) => { + return StyleSheet.create({ + container: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: changeOpacity(theme.centerChannelColor, 0.48), + borderRadius: 10, + paddingLeft: 5, + paddingRight: 5, + }, + text: { + paddingTop: 1.5, + paddingBottom: 1.5, + paddingLeft: 2.5, + paddingRight: 2.5, + ...typography('Body', 50, 'SemiBold'), + color: theme.centerChannelBg, + }, + }); +}; + +const Pill = ({text}: Props) => { + const theme = useTheme(); + const styles = useMemo(() => getStyleSheet({theme}), [theme]); + + return ( + + + {text} + + + ); +}; + +export default Pill; diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 5707b3b4f..80286c143 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -268,6 +268,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.CALL: screen = withServerDatabase(require('@calls/screens/call_screen').default); break; + case Screens.CALL_PARTICIPANTS: + screen = withServerDatabase(require('@calls/screens/participants_list').default); + break; } if (screen) { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index d097253c7..4a4ec368d 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -499,6 +499,8 @@ "mobile.calls_participant_rec_title": "Recording is in progress", "mobile.calls_participant_transcription": "The host has started recording and transcription for this meeting. By staying in the meeting, you give consent to being recorded and transcribed.", "mobile.calls_participant_transcription_title": "Recording and transcription is in progress", + "mobile.calls_participants": "Participants", + "mobile.calls_people": "People", "mobile.calls_phone": "Phone", "mobile.calls_quality_warning": "Call quality may be degraded due to unstable network conditions.", "mobile.calls_raise_hand": "Raise hand",