[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
This commit is contained in:
Christopher Poile 2024-04-22 11:01:18 -04:00 committed by GitHub
parent a41c3a149e
commit 1774445715
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 379 additions and 12 deletions

View file

@ -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<string>([
THREAD_OPTIONS,
REACTIONS,
USER_PROFILE,
CALL_PARTICIPANTS,
]);
export const NOT_READY = [

View file

@ -77,13 +77,18 @@ export const observeIsCallLimitRestricted = (database: Database, serverUrl: stri
) as Observable<LimitRestrictedInfo>;
};
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(

View file

@ -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}
/>
</Pressable>
<Pressable
style={[style.button, isLandscape && style.buttonLandscape]}
onPress={openParticipantsList}
>
<CompassIcon
name={'account-multiple-outline'}
size={32}
style={[style.buttonIcon, isLandscape && style.buttonIconLandscape]}
/>
<FormattedText
id={'mobile.calls_people'}
defaultMessage={'People'}
style={style.buttonText}
/>
</Pressable>
{!isLandscape && (isHost || ccAvailable) &&
<Pressable
style={[style.button, isLandscape && style.buttonLandscape]}

View file

@ -5,30 +5,23 @@ import {withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {observeCurrentSessionsDict} from '@calls/observers';
import {observeCallDatabase, observeCurrentSessionsDict} from '@calls/observers';
import CallScreen from '@calls/screens/call_screen/call_screen';
import {observeCurrentCall, observeGlobalCallsState} from '@calls/state';
import DatabaseManager from '@database/manager';
import {observeTeammateNameDisplay} from '@queries/servers/user';
const enhanced = withObservables([], () => {
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,

View file

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

View file

@ -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 (
<Pressable
key={sess.sessionId}
testID='users-list'
style={styles.rowContainer}
>
{sess.userModel ? (
<ProfilePicture
author={sess.userModel}
size={PROFILE_SIZE}
showStatus={false}
url={currentCall.serverUrl}
/>
) : (
<CompassIcon
name='account-outline'
size={PROFILE_SIZE}
style={styles.profileIcon}
/>
)}
<View style={styles.row}>
<Text
style={styles.name}
numberOfLines={1}
>
{displayUsername(sess.userModel, intl.locale, teammateNameDisplay)}
</Text>
{sess.sessionId === currentCall.mySessionId &&
<Text style={styles.you}>
{intl.formatMessage({id: 'mobile.calls_you', defaultMessage: '(you)'})}
</Text>
}
{sess.userId === currentCall.hostId &&
<Tag
id={'mobile.calls_host'}
defaultMessage={'host'}
style={styles.hostTag}
/>
}
</View>
<View style={styles.icons}>
{sess.reaction?.emoji !== undefined &&
<Emoji
emojiName={sess.reaction.emoji.name}
literal={sess.reaction.emoji.literal}
size={24 - Platform.select({ios: 3, default: 4})}
/>
}
{sess.raisedHand !== 0 &&
<CompassIcon
name={'hand-right'}
size={24}
style={styles.raiseHandIcon}
/>
}
{sess.sessionId === currentCall.screenOn &&
<CompassIcon
name={'monitor'}
size={24}
style={styles.screenSharingIcon}
/>
}
<CompassIcon
name={sess.muted ? 'microphone-off' : 'microphone'}
size={24}
style={sess.muted ? styles.muteIcon : styles.unmutedIcon}
/>
</View>
</Pressable>
);
};

View file

@ -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<CallSession>;
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<CallSession>) => (
<Participant
key={item.sessionId}
sess={item}
teammateNameDisplay={teammateNameDisplay}
/>
), [teammateNameDisplay]);
const renderContent = () => {
return (
<>
<View style={styles.header}>
<FormattedText
style={styles.headerText}
id={'mobile.calls_participants'}
defaultMessage={'Participants'}
/>
<Pill text={sessions.length}/>
</View>
<List
data={sessions}
renderItem={renderItem}
overScrollMode={'auto'}
/>
<View style={{height: bottom}}/>
</>
);
};
return (
<BottomSheet
renderContent={renderContent}
closeButtonId={closeButtonId}
componentId={Screens.CALL_PARTICIPANTS}
snapPoints={snapPoints}
/>
);
};

View file

@ -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 (
<View style={styles.container}>
<Text style={styles.text}>
{text}
</Text>
</View>
);
};
export default Pill;

View file

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

View file

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