[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:
parent
a41c3a149e
commit
1774445715
10 changed files with 379 additions and 12 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
24
app/products/calls/screens/participants_list/index.ts
Normal file
24
app/products/calls/screens/participants_list/index.ts
Normal 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);
|
||||
159
app/products/calls/screens/participants_list/participant.tsx
Normal file
159
app/products/calls/screens/participants_list/participant.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
51
app/products/calls/screens/participants_list/pill.tsx
Normal file
51
app/products/calls/screens/participants_list/pill.tsx
Normal 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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue