[Gekidou MM-47652] Add Create DM Add Bottom Panel (#6699)
This commit is contained in:
parent
25fcb066a7
commit
e4c38bd5a0
10 changed files with 443 additions and 282 deletions
265
app/components/selected_users/index.tsx
Normal file
265
app/components/selected_users/index.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {LayoutChangeEvent, Platform, ScrollView, useWindowDimensions, View} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import Toast from '@components/toast';
|
||||
import {General} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet, useKeyboardHeightWithDuration} from '@hooks/device';
|
||||
import Button from '@screens/bottom_sheet/button';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import SelectedUser, {USER_CHIP_BOTTOM_MARGIN, USER_CHIP_HEIGHT} from './selected_user';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Name of the button Icon
|
||||
*/
|
||||
buttonIcon: string;
|
||||
|
||||
/*
|
||||
* Text displayed on the action button
|
||||
*/
|
||||
buttonText: string;
|
||||
|
||||
/**
|
||||
* the height of the parent container
|
||||
*/
|
||||
containerHeight?: number;
|
||||
|
||||
/**
|
||||
* the Y position of the first view in the parent container
|
||||
*/
|
||||
modalPosition?: number;
|
||||
|
||||
/**
|
||||
* A handler function that will select or deselect a user when clicked on.
|
||||
*/
|
||||
onPress: (selectedId?: {[id: string]: boolean}) => void;
|
||||
|
||||
/**
|
||||
* A handler function that will deselect a user when clicked on.
|
||||
*/
|
||||
onRemove: (id: string) => void;
|
||||
|
||||
/**
|
||||
* An object mapping user ids to a falsey value indicating whether or not they have been selected.
|
||||
*/
|
||||
selectedIds: {[id: string]: UserProfile};
|
||||
|
||||
/**
|
||||
* callback to set the value of showToast
|
||||
*/
|
||||
setShowToast: (show: boolean) => void;
|
||||
|
||||
/**
|
||||
* show the toast
|
||||
*/
|
||||
showToast: boolean;
|
||||
|
||||
/**
|
||||
* How to display the names of users.
|
||||
*/
|
||||
teammateNameDisplay: string;
|
||||
|
||||
/**
|
||||
* toast Icon
|
||||
*/
|
||||
toastIcon?: string;
|
||||
|
||||
/**
|
||||
* toast Message
|
||||
*/
|
||||
toastMessage: string;
|
||||
}
|
||||
|
||||
const BUTTON_HEIGHT = 48;
|
||||
const CHIP_HEIGHT_WITH_MARGIN = USER_CHIP_HEIGHT + USER_CHIP_BOTTOM_MARGIN;
|
||||
const EXPOSED_CHIP_HEIGHT = 0.33 * USER_CHIP_HEIGHT;
|
||||
const MAX_CHIP_ROWS = 2;
|
||||
const SCROLL_PADDING_TOP = 20;
|
||||
const PANEL_MAX_HEIGHT = SCROLL_PADDING_TOP + (CHIP_HEIGHT_WITH_MARGIN * MAX_CHIP_ROWS) + EXPOSED_CHIP_HEIGHT;
|
||||
const TABLET_MARGIN_BOTTOM = 20;
|
||||
const TOAST_BOTTOM_MARGIN = 24;
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
borderBottomWidth: 0,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
borderWidth: 1,
|
||||
maxHeight: PANEL_MAX_HEIGHT + BUTTON_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
paddingHorizontal: 20,
|
||||
shadowColor: theme.centerChannelColor,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 8,
|
||||
},
|
||||
shadowOpacity: 0.16,
|
||||
shadowRadius: 24,
|
||||
},
|
||||
toast: {
|
||||
backgroundColor: theme.centerChannelColor,
|
||||
},
|
||||
users: {
|
||||
paddingTop: SCROLL_PADDING_TOP,
|
||||
paddingBottom: 12,
|
||||
flexDirection: 'row',
|
||||
flexGrow: 1,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
message: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.6),
|
||||
fontSize: 12,
|
||||
marginRight: 5,
|
||||
marginTop: 10,
|
||||
marginBottom: 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function SelectedUsers({
|
||||
buttonIcon, buttonText, containerHeight = 0,
|
||||
modalPosition = 0, onPress, onRemove,
|
||||
selectedIds, setShowToast, showToast = false,
|
||||
teammateNameDisplay, toastIcon, toastMessage,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const style = getStyleFromTheme(theme);
|
||||
const isTablet = useIsTablet();
|
||||
const keyboard = useKeyboardHeightWithDuration();
|
||||
const insets = useSafeAreaInsets();
|
||||
const dimensions = useWindowDimensions();
|
||||
|
||||
const panelHeight = useSharedValue(0);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const numberSelectedIds = Object.keys(selectedIds).length;
|
||||
const bottomSpace = (dimensions.height - containerHeight - modalPosition);
|
||||
|
||||
const users = useMemo(() => {
|
||||
const u = [];
|
||||
for (const id of Object.keys(selectedIds)) {
|
||||
if (!selectedIds[id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
u.push(
|
||||
<SelectedUser
|
||||
key={id}
|
||||
user={selectedIds[id]}
|
||||
teammateNameDisplay={teammateNameDisplay}
|
||||
onRemove={onRemove}
|
||||
testID='create_direct_message.selected_user'
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return u;
|
||||
}, [selectedIds, teammateNameDisplay, onRemove]);
|
||||
|
||||
const totalPanelHeight = useDerivedValue(() => (
|
||||
isVisible ? panelHeight.value + BUTTON_HEIGHT : 0
|
||||
), [isVisible, isTablet]);
|
||||
|
||||
const marginBottom = useMemo(() => {
|
||||
let margin = keyboard.height && Platform.OS === 'ios' ? keyboard.height - insets.bottom : 0;
|
||||
if (isTablet) {
|
||||
margin = keyboard.height ? (keyboard.height - bottomSpace - insets.bottom) : 0;
|
||||
}
|
||||
return margin;
|
||||
}, [keyboard, isTablet, insets.bottom, bottomSpace]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress();
|
||||
}, [onPress]);
|
||||
|
||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
panelHeight.value = Math.min(PANEL_MAX_HEIGHT, e.nativeEvent.layout.height);
|
||||
}, []);
|
||||
|
||||
const androidMaxHeight = Platform.select({
|
||||
android: {
|
||||
maxHeight: isVisible ? undefined : 0,
|
||||
},
|
||||
});
|
||||
|
||||
const animatedContainerStyle = useAnimatedStyle(() => ({
|
||||
marginBottom: withTiming(marginBottom, {duration: keyboard.duration}),
|
||||
paddingBottom: (isVisible || Platform.OS === 'android') ? TABLET_MARGIN_BOTTOM + insets.bottom : 0,
|
||||
backgroundColor: isVisible ? theme.centerChannelBg : 'transparent',
|
||||
...androidMaxHeight,
|
||||
}), [marginBottom, keyboard.duration, isVisible, theme.centerChannelBg]);
|
||||
|
||||
const animatedToastStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
bottom: TOAST_BOTTOM_MARGIN + totalPanelHeight.value,
|
||||
opacity: withTiming(showToast ? 1 : 0, {duration: 250}),
|
||||
position: 'absolute',
|
||||
};
|
||||
}, [showToast, keyboard]);
|
||||
|
||||
const animatedViewStyle = useAnimatedStyle(() => ({
|
||||
height: withTiming(totalPanelHeight.value, {duration: 250}),
|
||||
borderWidth: isVisible ? 1 : 0,
|
||||
maxHeight: isVisible ? PANEL_MAX_HEIGHT + BUTTON_HEIGHT : 0,
|
||||
}), [totalPanelHeight.value, isVisible]);
|
||||
|
||||
const animatedButtonStyle = useAnimatedStyle(() => ({
|
||||
opacity: withTiming(isVisible ? 1 : 0, {duration: isVisible ? 500 : 100}),
|
||||
}), [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(numberSelectedIds > 0);
|
||||
}, [numberSelectedIds > 0]);
|
||||
|
||||
// This effect hides the toast after 4 seconds
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (showToast) {
|
||||
timer = setTimeout(() => {
|
||||
setShowToast(false);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [showToast]);
|
||||
|
||||
return (
|
||||
<Animated.View style={animatedContainerStyle}>
|
||||
{showToast &&
|
||||
<Toast
|
||||
animatedStyle={animatedToastStyle}
|
||||
iconName={toastIcon}
|
||||
style={style.toast}
|
||||
message={toastMessage}
|
||||
/>
|
||||
}
|
||||
<Animated.View style={[style.container, animatedViewStyle]}>
|
||||
<ScrollView>
|
||||
<View
|
||||
style={style.users}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{users}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<Animated.View style={animatedButtonStyle}>
|
||||
<Button
|
||||
onPress={handlePress}
|
||||
icon={buttonIcon}
|
||||
text={buttonText}
|
||||
disabled={numberSelectedIds > General.MAX_USERS_IN_GM}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -8,8 +8,10 @@ import {
|
|||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ProfilePicture from '@components/profile_picture';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
|
@ -38,6 +40,10 @@ type Props = {
|
|||
testID?: string;
|
||||
}
|
||||
|
||||
export const USER_CHIP_HEIGHT = 32;
|
||||
export const USER_CHIP_BOTTOM_MARGIN = 8;
|
||||
const FADE_DURATION = 100;
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
|
|
@ -45,16 +51,22 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
|||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
borderRadius: 16,
|
||||
height: USER_CHIP_HEIGHT,
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
marginBottom: 8,
|
||||
marginRight: 10,
|
||||
paddingLeft: 12,
|
||||
paddingVertical: 8,
|
||||
paddingRight: 7,
|
||||
marginBottom: USER_CHIP_BOTTOM_MARGIN,
|
||||
marginRight: 8,
|
||||
paddingHorizontal: 7,
|
||||
},
|
||||
remove: {
|
||||
justifyContent: 'center',
|
||||
marginLeft: 7,
|
||||
},
|
||||
profileContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 8,
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
text: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Body', 100, 'SemiBold'),
|
||||
|
|
@ -76,11 +88,22 @@ export default function SelectedUser({
|
|||
onRemove(user.id);
|
||||
}, [onRemove, user.id]);
|
||||
|
||||
const userItemTestID = `${testID}.${user.id}`;
|
||||
return (
|
||||
<View
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(FADE_DURATION)}
|
||||
exiting={FadeOut.duration(FADE_DURATION)}
|
||||
style={style.container}
|
||||
testID={`${testID}.${user.id}`}
|
||||
>
|
||||
<View style={style.profileContainer}>
|
||||
<ProfilePicture
|
||||
author={user}
|
||||
size={20}
|
||||
iconSize={20}
|
||||
testID={`${userItemTestID}.profile_picture`}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={style.text}
|
||||
testID={`${testID}.${user.id}.display_name`}
|
||||
|
|
@ -94,10 +117,10 @@ export default function SelectedUser({
|
|||
>
|
||||
<CompassIcon
|
||||
name='close-circle'
|
||||
size={17}
|
||||
size={18}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.32)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,11 +40,11 @@ exports[`components/channel_list_row should show no results 1`] = `
|
|||
"username": "johndoe",
|
||||
},
|
||||
],
|
||||
"first": true,
|
||||
"id": "J",
|
||||
},
|
||||
]
|
||||
}
|
||||
extraData={false}
|
||||
getItem={[Function]}
|
||||
getItemCount={[Function]}
|
||||
initialNumToRender={15}
|
||||
|
|
@ -237,7 +237,7 @@ exports[`components/channel_list_row should show no results 1`] = `
|
|||
}
|
||||
>
|
||||
<Icon
|
||||
color="rgba(63,67,80,0.1024)"
|
||||
color="rgba(63,67,80,0.32)"
|
||||
name="circle-outline"
|
||||
size={28}
|
||||
/>
|
||||
|
|
@ -312,11 +312,11 @@ exports[`components/channel_list_row should show results and tutorial 1`] = `
|
|||
"username": "johndoe",
|
||||
},
|
||||
],
|
||||
"first": true,
|
||||
"id": "J",
|
||||
},
|
||||
]
|
||||
}
|
||||
extraData={false}
|
||||
getItem={[Function]}
|
||||
getItemCount={[Function]}
|
||||
initialNumToRender={15}
|
||||
|
|
@ -509,7 +509,7 @@ exports[`components/channel_list_row should show results and tutorial 1`] = `
|
|||
}
|
||||
>
|
||||
<Icon
|
||||
color="rgba(63,67,80,0.1024)"
|
||||
color="rgba(63,67,80,0.32)"
|
||||
name="circle-outline"
|
||||
size={28}
|
||||
/>
|
||||
|
|
@ -584,11 +584,11 @@ exports[`components/channel_list_row should show results no tutorial 1`] = `
|
|||
"username": "johndoe",
|
||||
},
|
||||
],
|
||||
"first": true,
|
||||
"id": "J",
|
||||
},
|
||||
]
|
||||
}
|
||||
extraData={false}
|
||||
getItem={[Function]}
|
||||
getItemCount={[Function]}
|
||||
initialNumToRender={15}
|
||||
|
|
@ -781,7 +781,7 @@ exports[`components/channel_list_row should show results no tutorial 1`] = `
|
|||
}
|
||||
>
|
||||
<Icon
|
||||
color="rgba(63,67,80,0.1024)"
|
||||
color="rgba(63,67,80,0.32)"
|
||||
name="circle-outline"
|
||||
size={28}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -57,9 +57,10 @@ export function createProfilesSections(profiles: UserProfile[]) {
|
|||
|
||||
sectionKeys.sort();
|
||||
|
||||
return sectionKeys.map((sectionKey) => {
|
||||
return sectionKeys.map((sectionKey, index) => {
|
||||
return {
|
||||
id: sectionKey,
|
||||
first: index === 0,
|
||||
data: sections[sectionKey],
|
||||
};
|
||||
});
|
||||
|
|
@ -70,11 +71,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
|||
list: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
flex: 1,
|
||||
...Platform.select({
|
||||
android: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
}),
|
||||
},
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
|
|
@ -174,12 +170,13 @@ export default function UserList({
|
|||
return (
|
||||
<UserListRow
|
||||
key={item.id}
|
||||
highlight={section?.id === data?.[0].id && index === 0}
|
||||
highlight={section?.first && index === 0}
|
||||
id={item.id}
|
||||
isMyUser={currentUserId === item.id}
|
||||
onPress={handleSelectProfile}
|
||||
onLongPress={openUserProfile}
|
||||
selectable={canAdd}
|
||||
disabled={!canAdd}
|
||||
selectable={true}
|
||||
selected={selected}
|
||||
testID='create_direct_message.user_list.user_item'
|
||||
teammateNameDisplay={teammateNameDisplay}
|
||||
|
|
@ -187,7 +184,7 @@ export default function UserList({
|
|||
user={item}
|
||||
/>
|
||||
);
|
||||
}, [selectedIds, currentUserId, handleSelectProfile, teammateNameDisplay, tutorialWatched, data]);
|
||||
}, [selectedIds, currentUserId, handleSelectProfile, teammateNameDisplay, tutorialWatched]);
|
||||
|
||||
const renderLoading = useCallback(() => {
|
||||
if (!loading) {
|
||||
|
|
@ -230,7 +227,6 @@ export default function UserList({
|
|||
<FlatList
|
||||
contentContainerStyle={style.container}
|
||||
data={items}
|
||||
extraData={selectedIds}
|
||||
keyboardShouldPersistTaps='always'
|
||||
{...keyboardDismissProp}
|
||||
keyExtractor={keyExtractor}
|
||||
|
|
@ -251,7 +247,6 @@ export default function UserList({
|
|||
return (
|
||||
<SectionList
|
||||
contentContainerStyle={style.container}
|
||||
extraData={loading ? false : selectedIds}
|
||||
keyboardShouldPersistTaps='always'
|
||||
{...keyboardDismissProp}
|
||||
keyExtractor={keyExtractor}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
|||
const DISABLED_OPACITY = 0.32;
|
||||
const DEFAULT_ICON_OPACITY = 0.32;
|
||||
|
||||
export default function UserListRow({
|
||||
function UserListRow({
|
||||
id,
|
||||
isMyUser,
|
||||
highlight,
|
||||
|
|
@ -124,7 +124,7 @@ export default function UserListRow({
|
|||
const bounds: TutorialItemBounds = {
|
||||
startX: x - 20,
|
||||
startY: y,
|
||||
endX: x + w + 20,
|
||||
endX: x + w,
|
||||
endY: y + h,
|
||||
};
|
||||
if (viewRef.current) {
|
||||
|
|
@ -160,7 +160,7 @@ export default function UserListRow({
|
|||
return null;
|
||||
}
|
||||
|
||||
const iconOpacity = DEFAULT_ICON_OPACITY * (disabled ? 1 : DISABLED_OPACITY);
|
||||
const iconOpacity = DEFAULT_ICON_OPACITY * (disabled ? DISABLED_OPACITY : 1);
|
||||
const color = selected ? theme.buttonBg : changeOpacity(theme.centerChannelColor, iconOpacity);
|
||||
return (
|
||||
<View style={style.selector}>
|
||||
|
|
@ -267,3 +267,4 @@ export default function UserListRow({
|
|||
);
|
||||
}
|
||||
|
||||
export default React.memo(UserListRow);
|
||||
|
|
|
|||
|
|
@ -45,21 +45,21 @@ export function useIsTablet() {
|
|||
return Device.IS_TABLET && !isSplitView;
|
||||
}
|
||||
|
||||
export function useKeyboardHeight(keyboardTracker?: React.RefObject<KeyboardTrackingViewRef>) {
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
export function useKeyboardHeightWithDuration(keyboardTracker?: React.RefObject<KeyboardTrackingViewRef>) {
|
||||
const [keyboardHeight, setKeyboardHeight] = useState({height: 0, duration: 0});
|
||||
const updateTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// This is a magic number. With tracking view, to properly get the final position, this had to be added.
|
||||
const KEYBOARD_TRACKINGVIEW_SEPARATION = 4;
|
||||
|
||||
const updateValue = (v: number) => {
|
||||
const updateValue = (height: number, duration: number) => {
|
||||
if (updateTimeout.current != null) {
|
||||
clearTimeout(updateTimeout.current);
|
||||
updateTimeout.current = null;
|
||||
}
|
||||
updateTimeout.current = setTimeout(() => {
|
||||
setKeyboardHeight(v);
|
||||
setKeyboardHeight({height, duration});
|
||||
updateTimeout.current = null;
|
||||
}, 200);
|
||||
};
|
||||
|
|
@ -69,21 +69,21 @@ export function useKeyboardHeight(keyboardTracker?: React.RefObject<KeyboardTrac
|
|||
if (keyboardTracker?.current) {
|
||||
const props = await keyboardTracker.current.getNativeProps();
|
||||
if (props.keyboardHeight) {
|
||||
updateValue((props.trackingViewHeight + props.keyboardHeight) - KEYBOARD_TRACKINGVIEW_SEPARATION);
|
||||
updateValue((props.trackingViewHeight + props.keyboardHeight) - KEYBOARD_TRACKINGVIEW_SEPARATION, event.duration);
|
||||
} else {
|
||||
updateValue((props.trackingViewHeight + insets.bottom) - KEYBOARD_TRACKINGVIEW_SEPARATION);
|
||||
updateValue((props.trackingViewHeight + insets.bottom) - KEYBOARD_TRACKINGVIEW_SEPARATION, event.duration);
|
||||
}
|
||||
} else {
|
||||
updateValue(event.endCoordinates.height);
|
||||
setKeyboardHeight({height: event.endCoordinates.height, duration: event.duration});
|
||||
}
|
||||
});
|
||||
|
||||
const hide = Keyboard.addListener(Platform.select({ios: 'keyboardWillHide', default: 'keyboardDidHide'}), () => {
|
||||
const hide = Keyboard.addListener(Platform.select({ios: 'keyboardWillHide', default: 'keyboardDidHide'}), (event) => {
|
||||
if (updateTimeout.current != null) {
|
||||
clearTimeout(updateTimeout.current);
|
||||
updateTimeout.current = null;
|
||||
}
|
||||
setKeyboardHeight(0);
|
||||
setKeyboardHeight({height: 0, duration: event.duration});
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
|
@ -95,9 +95,15 @@ export function useKeyboardHeight(keyboardTracker?: React.RefObject<KeyboardTrac
|
|||
return keyboardHeight;
|
||||
}
|
||||
|
||||
export function useKeyboardHeight(keyboardTracker?: React.RefObject<KeyboardTrackingViewRef>) {
|
||||
const {height} = useKeyboardHeightWithDuration(keyboardTracker);
|
||||
return height;
|
||||
}
|
||||
|
||||
export function useModalPosition(viewRef: RefObject<View>, deps?: React.DependencyList) {
|
||||
const [modalPosition, setModalPosition] = useState(0);
|
||||
const isTablet = useIsTablet();
|
||||
const height = useKeyboardHeight();
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'ios' && isTablet) {
|
||||
|
|
@ -107,7 +113,7 @@ export function useModalPosition(viewRef: RefObject<View>, deps?: React.Dependen
|
|||
}
|
||||
});
|
||||
}
|
||||
}, deps);
|
||||
}, [...(deps || []), isTablet, height]);
|
||||
|
||||
return modalPosition;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const styles = StyleSheet.create({
|
|||
width: 24,
|
||||
height: 24,
|
||||
marginTop: 2,
|
||||
marginRight: 8,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Keyboard, Platform, View} from 'react-native';
|
||||
import {defineMessages, useIntl} from 'react-intl';
|
||||
import {Keyboard, LayoutChangeEvent, Platform, View} from 'react-native';
|
||||
import {SafeAreaView} from 'react-native-safe-area-context';
|
||||
|
||||
import {makeDirectChannel, makeGroupChannel} from '@actions/remote/channel';
|
||||
|
|
@ -11,11 +11,13 @@ import {fetchProfiles, fetchProfilesInTeam, searchProfiles} from '@actions/remot
|
|||
import CompassIcon from '@components/compass_icon';
|
||||
import Loading from '@components/loading';
|
||||
import Search from '@components/search';
|
||||
import SelectedUsers from '@components/selected_users';
|
||||
import UserList from '@components/user_list';
|
||||
import {General} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {debounce} from '@helpers/api/general';
|
||||
import {useModalPosition} from '@hooks/device';
|
||||
import useNavButtonPressed from '@hooks/navigation_button_pressed';
|
||||
import {t} from '@i18n';
|
||||
import {dismissModal, setButtons} from '@screens/navigation';
|
||||
|
|
@ -24,9 +26,25 @@ import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme}
|
|||
import {typography} from '@utils/typography';
|
||||
import {displayUsername, filterProfilesMatchingTerm} from '@utils/user';
|
||||
|
||||
import SelectedUsers from './selected_users';
|
||||
const messages = defineMessages({
|
||||
dm: {
|
||||
id: t('mobile.open_dm.error'),
|
||||
defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
|
||||
},
|
||||
gm: {
|
||||
id: t('mobile.open_gm.error'),
|
||||
defaultMessage: "We couldn't open a group message with those users. Please check your connection and try again.",
|
||||
},
|
||||
buttonText: {
|
||||
id: t('mobile.create_direct_message.start'),
|
||||
defaultMessage: 'Start Conversation',
|
||||
},
|
||||
toastMessage: {
|
||||
id: t('mobile.create_direct_message.max_limit_reached'),
|
||||
defaultMessage: 'Group messages are limited to {maxCount} members',
|
||||
},
|
||||
});
|
||||
|
||||
const START_BUTTON = 'start-conversation';
|
||||
const CLOSE_BUTTON = 'close-dms';
|
||||
|
||||
type Props = {
|
||||
|
|
@ -43,7 +61,7 @@ const close = () => {
|
|||
dismissModal();
|
||||
};
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
flex: 1,
|
||||
|
|
@ -82,6 +100,13 @@ function reduceProfiles(state: UserProfile[], action: {type: 'add'; values?: Use
|
|||
return state;
|
||||
}
|
||||
|
||||
function removeProfileFromList(list: {[id: string]: UserProfile}, id: string) {
|
||||
const newSelectedIds = Object.assign({}, list);
|
||||
|
||||
Reflect.deleteProperty(newSelectedIds, id);
|
||||
return newSelectedIds;
|
||||
}
|
||||
|
||||
export default function CreateDirectMessage({
|
||||
componentId,
|
||||
currentTeamId,
|
||||
|
|
@ -100,6 +125,8 @@ export default function CreateDirectMessage({
|
|||
const next = useRef(true);
|
||||
const page = useRef(-1);
|
||||
const mounted = useRef(false);
|
||||
const mainView = useRef<View>(null);
|
||||
const modalPosition = useModalPosition(mainView);
|
||||
|
||||
const [profiles, dispatchProfiles] = useReducer(reduceProfiles, []);
|
||||
const [searchResults, setSearchResults] = useState<UserProfile[]>([]);
|
||||
|
|
@ -107,6 +134,8 @@ export default function CreateDirectMessage({
|
|||
const [term, setTerm] = useState('');
|
||||
const [startingConversation, setStartingConversation] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const selectedCount = Object.keys(selectedIds).length;
|
||||
|
||||
const isSearch = Boolean(term);
|
||||
|
|
@ -123,6 +152,28 @@ export default function CreateDirectMessage({
|
|||
}
|
||||
};
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (term) {
|
||||
const exactMatches: UserProfile[] = [];
|
||||
const filterByTerm = (p: UserProfile) => {
|
||||
if (selectedCount > 0 && p.id === currentUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (p.username === term || p.username.startsWith(term)) {
|
||||
exactMatches.push(p);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const results = filterProfilesMatchingTerm(searchResults, term).filter(filterByTerm);
|
||||
return [...exactMatches, ...results];
|
||||
}
|
||||
return profiles;
|
||||
}, [term, isSearch && selectedCount, isSearch && searchResults, profiles]);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setTerm('');
|
||||
setSearchResults([]);
|
||||
|
|
@ -140,32 +191,16 @@ export default function CreateDirectMessage({
|
|||
}, 100), [loading, isSearch, restrictDirectMessage, serverUrl, currentTeamId]);
|
||||
|
||||
const handleRemoveProfile = useCallback((id: string) => {
|
||||
const newSelectedIds = Object.assign({}, selectedIds);
|
||||
|
||||
Reflect.deleteProperty(newSelectedIds, id);
|
||||
|
||||
setSelectedIds(newSelectedIds);
|
||||
}, [selectedIds]);
|
||||
setSelectedIds((current) => removeProfileFromList(current, id));
|
||||
}, []);
|
||||
|
||||
const createDirectChannel = useCallback(async (id: string): Promise<boolean> => {
|
||||
const user = selectedIds[id];
|
||||
|
||||
const displayName = displayUsername(user, intl.locale, teammateNameDisplay);
|
||||
|
||||
const result = await makeDirectChannel(serverUrl, id, displayName);
|
||||
|
||||
if (result.error) {
|
||||
alertErrorWithFallback(
|
||||
intl,
|
||||
result.error,
|
||||
{
|
||||
id: 'mobile.open_dm.error',
|
||||
defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
|
||||
},
|
||||
{
|
||||
displayName,
|
||||
},
|
||||
);
|
||||
alertErrorWithFallback(intl, result.error, messages.dm);
|
||||
}
|
||||
|
||||
return !result.error;
|
||||
|
|
@ -175,14 +210,7 @@ export default function CreateDirectMessage({
|
|||
const result = await makeGroupChannel(serverUrl, ids);
|
||||
|
||||
if (result.error) {
|
||||
alertErrorWithFallback(
|
||||
intl,
|
||||
result.error,
|
||||
{
|
||||
id: t('mobile.open_gm.error'),
|
||||
defaultMessage: "We couldn't open a group message with those users. Please check your connection and try again.",
|
||||
},
|
||||
);
|
||||
alertErrorWithFallback(intl, result.error, messages.gm);
|
||||
}
|
||||
|
||||
return !result.error;
|
||||
|
|
@ -213,11 +241,6 @@ export default function CreateDirectMessage({
|
|||
}, [startingConversation, selectedIds, createGroupChannel, createDirectChannel]);
|
||||
|
||||
const handleSelectProfile = useCallback((user: UserProfile) => {
|
||||
if (selectedIds[user.id]) {
|
||||
handleRemoveProfile(user.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.id === currentUserId) {
|
||||
const selectedId = {
|
||||
[currentUserId]: true,
|
||||
|
|
@ -225,22 +248,28 @@ export default function CreateDirectMessage({
|
|||
|
||||
startConversation(selectedId);
|
||||
} else {
|
||||
const wasSelected = selectedIds[user.id];
|
||||
|
||||
if (!wasSelected && selectedCount >= General.MAX_USERS_IN_GM) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSelectedIds = Object.assign({}, selectedIds);
|
||||
if (!wasSelected) {
|
||||
newSelectedIds[user.id] = user;
|
||||
}
|
||||
|
||||
setSelectedIds(newSelectedIds);
|
||||
|
||||
clearSearch();
|
||||
setSelectedIds((current) => {
|
||||
if (current[user.id]) {
|
||||
return removeProfileFromList(current, user.id);
|
||||
}
|
||||
|
||||
const wasSelected = current[user.id];
|
||||
|
||||
if (!wasSelected && selectedCount >= General.MAX_USERS_IN_GM) {
|
||||
setShowToast(true);
|
||||
return current;
|
||||
}
|
||||
|
||||
const newSelectedIds = Object.assign({}, current);
|
||||
if (!wasSelected) {
|
||||
newSelectedIds[user.id] = user;
|
||||
}
|
||||
|
||||
return newSelectedIds;
|
||||
});
|
||||
}
|
||||
}, [selectedIds, currentUserId, handleRemoveProfile, startConversation, clearSearch]);
|
||||
}, [currentUserId, clearSearch]);
|
||||
|
||||
const searchUsers = useCallback(async (searchTerm: string) => {
|
||||
const lowerCasedTerm = searchTerm.toLowerCase();
|
||||
|
|
@ -253,12 +282,12 @@ export default function CreateDirectMessage({
|
|||
results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: true});
|
||||
}
|
||||
|
||||
let data: UserProfile[] = [];
|
||||
let searchData: UserProfile[] = [];
|
||||
if (results.data) {
|
||||
data = results.data;
|
||||
searchData = results.data;
|
||||
}
|
||||
|
||||
setSearchResults(data);
|
||||
setSearchResults(searchData);
|
||||
setLoading(false);
|
||||
}, [restrictDirectMessage, serverUrl, currentTeamId]);
|
||||
|
||||
|
|
@ -266,6 +295,10 @@ export default function CreateDirectMessage({
|
|||
searchUsers(term);
|
||||
}, [searchUsers, term]);
|
||||
|
||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
setContainerHeight(e.nativeEvent.layout.height);
|
||||
}, []);
|
||||
|
||||
const onSearch = useCallback((text: string) => {
|
||||
if (text) {
|
||||
setTerm(text);
|
||||
|
|
@ -281,7 +314,7 @@ export default function CreateDirectMessage({
|
|||
}
|
||||
}, [searchUsers, clearSearch]);
|
||||
|
||||
const updateNavigationButtons = useCallback(async (startEnabled: boolean) => {
|
||||
const updateNavigationButtons = useCallback(async () => {
|
||||
const closeIcon = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
|
||||
setButtons(componentId, {
|
||||
leftButtons: [{
|
||||
|
|
@ -289,23 +322,14 @@ export default function CreateDirectMessage({
|
|||
icon: closeIcon,
|
||||
testID: 'close.create_direct_message.button',
|
||||
}],
|
||||
rightButtons: [{
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
id: START_BUTTON,
|
||||
text: formatMessage({id: 'mobile.create_direct_message.start', defaultMessage: 'Start'}),
|
||||
showAsAction: 'always',
|
||||
enabled: startEnabled,
|
||||
testID: 'create_direct_message.start.button',
|
||||
}],
|
||||
});
|
||||
}, [intl.locale, theme]);
|
||||
|
||||
useNavButtonPressed(START_BUTTON, componentId, startConversation, [startConversation]);
|
||||
useNavButtonPressed(CLOSE_BUTTON, componentId, close, [close]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
updateNavigationButtons(false);
|
||||
updateNavigationButtons();
|
||||
getProfiles();
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
|
|
@ -313,31 +337,8 @@ export default function CreateDirectMessage({
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canStart = selectedCount > 0 && !startingConversation;
|
||||
updateNavigationButtons(canStart);
|
||||
}, [selectedCount > 0, startingConversation, updateNavigationButtons]);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (term) {
|
||||
const exactMatches: UserProfile[] = [];
|
||||
const filterByTerm = (p: UserProfile) => {
|
||||
if (selectedCount > 0 && p.id === currentUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (p.username === term || p.username.startsWith(term)) {
|
||||
exactMatches.push(p);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const results = filterProfilesMatchingTerm(searchResults, term).filter(filterByTerm);
|
||||
return [...exactMatches, ...results];
|
||||
}
|
||||
return profiles;
|
||||
}, [term, isSearch && selectedCount, isSearch && searchResults, profiles]);
|
||||
setShowToast(selectedCount >= General.MAX_USERS_IN_GM);
|
||||
}, [selectedCount >= General.MAX_USERS_IN_GM]);
|
||||
|
||||
if (startingConversation) {
|
||||
return (
|
||||
|
|
@ -351,12 +352,15 @@ export default function CreateDirectMessage({
|
|||
<SafeAreaView
|
||||
style={style.container}
|
||||
testID='create_direct_message.screen'
|
||||
onLayout={onLayout}
|
||||
ref={mainView}
|
||||
edges={['top', 'left', 'right']}
|
||||
>
|
||||
<View style={style.searchBar}>
|
||||
<Search
|
||||
testID='create_direct_message.search_bar'
|
||||
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
|
||||
cancelButtonTitle={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
|
||||
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
|
||||
cancelButtonTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
|
||||
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
onChangeText={onSearch}
|
||||
onSubmitEditing={search}
|
||||
|
|
@ -366,15 +370,6 @@ export default function CreateDirectMessage({
|
|||
value={term}
|
||||
/>
|
||||
</View>
|
||||
{selectedCount > 0 &&
|
||||
<SelectedUsers
|
||||
selectedIds={selectedIds}
|
||||
warnCount={General.MAX_USERS_IN_GM - 2}
|
||||
maxCount={General.MAX_USERS_IN_GM}
|
||||
onRemove={handleRemoveProfile}
|
||||
teammateNameDisplay={teammateNameDisplay}
|
||||
/>
|
||||
}
|
||||
<UserList
|
||||
currentUserId={currentUserId}
|
||||
handleSelectProfile={handleSelectProfile}
|
||||
|
|
@ -388,6 +383,20 @@ export default function CreateDirectMessage({
|
|||
testID='create_direct_message.user_list'
|
||||
tutorialWatched={tutorialWatched}
|
||||
/>
|
||||
<SelectedUsers
|
||||
containerHeight={containerHeight}
|
||||
modalPosition={modalPosition}
|
||||
showToast={showToast}
|
||||
setShowToast={setShowToast}
|
||||
toastIcon={'check'}
|
||||
toastMessage={formatMessage(messages.toastMessage, {maxCount: General.MAX_USERS_IN_GM})}
|
||||
selectedIds={selectedIds}
|
||||
onRemove={handleRemoveProfile}
|
||||
teammateNameDisplay={teammateNameDisplay}
|
||||
onPress={startConversation}
|
||||
buttonIcon={'forum-outline'}
|
||||
buttonText={formatMessage(messages.buttonText)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import SelectedUser from './selected_user';
|
||||
|
||||
type Props = {
|
||||
|
||||
/*
|
||||
* An object mapping user ids to a falsey value indicating whether or not they've been selected.
|
||||
*/
|
||||
selectedIds: {[id: string]: UserProfile};
|
||||
|
||||
/*
|
||||
* How to display the names of users.
|
||||
*/
|
||||
teammateNameDisplay: string;
|
||||
|
||||
/*
|
||||
* The number of users that will be selected when we start to display a message indicating
|
||||
* the remaining number of users that can be selected.
|
||||
*/
|
||||
warnCount: number;
|
||||
|
||||
/*
|
||||
* The maximum number of users that can be selected.
|
||||
*/
|
||||
maxCount: number;
|
||||
|
||||
/*
|
||||
* A handler function that will deselect a user when clicked on.
|
||||
*/
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
marginHorizontal: 12,
|
||||
},
|
||||
users: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
message: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.6),
|
||||
fontSize: 12,
|
||||
marginRight: 5,
|
||||
marginTop: 10,
|
||||
marginBottom: 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function SelectedUsers({
|
||||
selectedIds,
|
||||
teammateNameDisplay,
|
||||
warnCount,
|
||||
maxCount,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const style = getStyleFromTheme(theme);
|
||||
|
||||
const users = useMemo(() => {
|
||||
const u = [];
|
||||
for (const id of Object.keys(selectedIds)) {
|
||||
if (!selectedIds[id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
u.push(
|
||||
<SelectedUser
|
||||
key={id}
|
||||
user={selectedIds[id]}
|
||||
teammateNameDisplay={teammateNameDisplay}
|
||||
onRemove={onRemove}
|
||||
testID='create_direct_message.selected_user'
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return u;
|
||||
}, [selectedIds, teammateNameDisplay, onRemove]);
|
||||
|
||||
const showWarn = users.length >= warnCount && users.length < maxCount;
|
||||
|
||||
const message = useMemo(() => {
|
||||
if (users.length >= maxCount) {
|
||||
return (
|
||||
<FormattedText
|
||||
style={style.message}
|
||||
id='mobile.create_direct_message.cannot_add_more'
|
||||
defaultMessage='You cannot add more users'
|
||||
/>
|
||||
);
|
||||
} else if (users.length >= warnCount) {
|
||||
const remaining = maxCount - users.length;
|
||||
if (remaining === 1) {
|
||||
return (
|
||||
<FormattedText
|
||||
style={style.message}
|
||||
id='mobile.create_direct_message.one_more'
|
||||
defaultMessage='You can add 1 more user'
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormattedText
|
||||
style={style.message}
|
||||
id='mobile.create_direct_message.add_more'
|
||||
defaultMessage='You can add {remaining, number} more users'
|
||||
values={{
|
||||
remaining,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [users.length >= maxCount, showWarn && users.length, theme, maxCount]);
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
<View style={style.users}>
|
||||
{users}
|
||||
</View>
|
||||
{message}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -296,6 +296,7 @@
|
|||
"general_settings.display": "Display",
|
||||
"general_settings.help": "Help",
|
||||
"general_settings.notifications": "Notifications",
|
||||
"general_settings.report_problem": "Report a Problem",
|
||||
"get_post_link_modal.title": "Copy Link",
|
||||
"global_threads.allThreads": "All Your Threads",
|
||||
"global_threads.emptyThreads.message": "Any threads you are mentioned in or have participated in will show here along with any threads you have followed.",
|
||||
|
|
@ -437,10 +438,8 @@
|
|||
"mobile.components.select_server_view.proceed": "Proceed",
|
||||
"mobile.create_channel": "Create",
|
||||
"mobile.create_channel.title": "New channel",
|
||||
"mobile.create_direct_message.add_more": "You can add {remaining, number} more users",
|
||||
"mobile.create_direct_message.cannot_add_more": "You cannot add more users",
|
||||
"mobile.create_direct_message.one_more": "You can add 1 more user",
|
||||
"mobile.create_direct_message.start": "Start",
|
||||
"mobile.create_direct_message.max_limit_reached": "Group messages are limited to {maxCount} members",
|
||||
"mobile.create_direct_message.start": "Start Conversation",
|
||||
"mobile.create_direct_message.you": "@{username} - you",
|
||||
"mobile.create_post.read_only": "This channel is read-only.",
|
||||
"mobile.custom_list.no_results": "No Results",
|
||||
|
|
@ -474,7 +473,6 @@
|
|||
"mobile.gallery.title": "{index} of {total}",
|
||||
"mobile.integration_selector.loading_channels": "Loading channels...",
|
||||
"mobile.integration_selector.loading_options": "Loading options...",
|
||||
"mobile.integration_selector.loading_users": "Loading users...",
|
||||
"mobile.ios.photos_permission_denied_description": "Upload photos and videos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo and video library.",
|
||||
"mobile.ios.photos_permission_denied_title": "{applicationName} would like to access your photos",
|
||||
"mobile.join_channel.error": "We couldn't join the channel {displayName}.",
|
||||
|
|
@ -527,6 +525,7 @@
|
|||
"mobile.onboarding.next": "Next",
|
||||
"mobile.onboarding.sign_in": "Sign in",
|
||||
"mobile.onboarding.sign_in_to_get_started": "Sign in to get started",
|
||||
"mobile.open_dm.error": "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
|
||||
"mobile.open_gm.error": "We couldn't open a group message with those users. Please check your connection and try again.",
|
||||
"mobile.participants.header": "Thread Participants",
|
||||
"mobile.permission_denied_dismiss": "Don't Allow",
|
||||
|
|
|
|||
Loading…
Reference in a new issue