[Gekidou] [MM-39718] Add Create DM screen (#5900)

* Add Create DM screen

* Add channel toggle and minor improvements

* Fix tests and apply new UI

* Address feedback UX feedback and fix missing menu item by adding another item height

* Add display name to channels and piggyback improvement on fetchUserByIds action and translations fix

* Address feedback

* Fix hardcoded colors

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Daniel Espino García
2022-03-11 16:57:31 +01:00
committed by GitHub
parent eec536a61b
commit b27ebce2e0
36 changed files with 1634 additions and 41 deletions

View File

@@ -7,11 +7,13 @@ import {IntlShape} from 'react-intl';
import {storeCategories} from '@actions/local/category';
import {storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
import {General} from '@constants';
import {General, Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {privateChannelJoinPrompt} from '@helpers/api/channel';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import NetworkManager from '@init/network_manager';
import {prepareMyChannelsForTeam, queryChannelById, queryChannelByName, queryMyChannel} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {queryCommonSystemValues, queryCurrentTeamId, queryCurrentUserId} from '@queries/servers/system';
import {prepareMyTeams, queryNthLastChannelFromTeam, queryMyTeamById, queryTeamById, queryTeamByName} from '@queries/servers/team';
import {queryCurrentUser} from '@queries/servers/user';
@@ -26,6 +28,7 @@ import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from './team';
import {fetchProfilesPerChannels, fetchUsersByIds} from './user';
import type {Client} from '@client/rest';
import type ChannelModel from '@typings/database/models/servers/channel';
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type MyTeamModel from '@typings/database/models/servers/my_team';
@@ -547,6 +550,69 @@ export const switchToChannelByName = async (serverUrl: string, channelName: stri
}
};
export const createDirectChannel = async (serverUrl: string, userId: string, displayName = '') => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const currentUser = await queryCurrentUser(operator.database);
if (!currentUser) {
return {error: 'Cannot get the current user'};
}
const created = await client.createDirectChannel([userId, currentUser.id]);
if (displayName) {
created.display_name = displayName;
} else {
const preferences = await queryPreferencesByCategoryAndName(operator.database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT);
const system = await queryCommonSystemValues(operator.database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], system.config, system.license);
const {directChannels} = await fetchMissingSidebarInfo(serverUrl, [created], currentUser.locale, teammateDisplayNameSetting, currentUser.id, true);
created.display_name = directChannels?.[0].display_name || created.display_name;
}
const member = {
channel_id: created.id,
user_id: currentUser.id,
roles: `${General.CHANNEL_USER_ROLE}`,
last_viewed_at: 0,
msg_count: 0,
mention_count: 0,
msg_count_root: 0,
mention_count_root: 0,
notify_props: {desktop: 'default' as const, mark_unread: 'all' as const},
last_update_at: created.create_at,
};
const models = [];
const channelPromises = await prepareMyChannelsForTeam(operator, '', [created], [member, {...member, user_id: userId}]);
if (channelPromises) {
const channelModels = await Promise.all(channelPromises);
const flattenedChannelModels = channelModels.flat();
if (flattenedChannelModels.length) {
models.push(...flattenedChannelModels);
}
}
if (models.length) {
await operator.batchRecords(models);
}
fetchRolesIfNeeded(serverUrl, member.roles.split(' '));
return {data: created};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchChannels = async (serverUrl: string, teamId: string, page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE) => {
let client: Client;
try {
@@ -565,6 +631,33 @@ export const fetchChannels = async (serverUrl: string, teamId: string, page = 0,
}
};
export const makeDirectChannel = async (serverUrl: string, userId: string, displayName = '', shouldSwitchToChannel = true) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const currentUserId = await queryCurrentUserId(operator.database);
const channelName = getDirectChannelName(userId, currentUserId);
let channel: Channel|ChannelModel|undefined = await queryChannelByName(operator.database, channelName);
let result: {data?: Channel|ChannelModel; error?: any};
if (channel) {
result = {data: channel};
} else {
result = await createDirectChannel(serverUrl, userId, displayName);
channel = result.data;
}
if (channel && shouldSwitchToChannel) {
switchToChannelById(serverUrl, channel.id);
}
return result;
} catch (error) {
return {error};
}
};
export const fetchArchivedChannels = async (serverUrl: string, teamId: string, page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE) => {
let client: Client;
try {
@@ -583,6 +676,70 @@ export const fetchArchivedChannels = async (serverUrl: string, teamId: string, p
}
};
export const createGroupChannel = async (serverUrl: string, userIds: string[]) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const currentUser = await queryCurrentUser(operator.database);
if (!currentUser) {
return {error: 'Cannot get the current user'};
}
const created = await client.createGroupChannel(userIds);
// Check the channel previous existency: if the channel already have
// posts is because it existed before.
if (created.total_msg_count > 0) {
return {data: created};
}
const preferences = await queryPreferencesByCategoryAndName(operator.database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT);
const system = await queryCommonSystemValues(operator.database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], system.config, system.license);
const {directChannels} = await fetchMissingSidebarInfo(serverUrl, [created], currentUser.locale, teammateDisplayNameSetting, currentUser.id, true);
const member = {
channel_id: created.id,
user_id: '',
roles: `${General.CHANNEL_USER_ROLE}`,
last_viewed_at: 0,
msg_count: 0,
mention_count: 0,
msg_count_root: 0,
mention_count_root: 0,
notify_props: {desktop: 'default' as const, mark_unread: 'all' as const},
last_update_at: created.create_at,
};
const members = userIds.map((id) => {
return {...member, user_id: id};
});
if (directChannels?.length) {
const channelPromises = await prepareMyChannelsForTeam(operator, '', directChannels, members);
if (channelPromises) {
const channelModels = await Promise.all(channelPromises);
const flattenedChannelModels = channelModels.flat();
if (flattenedChannelModels.length) {
operator.batchRecords(flattenedChannelModels);
}
}
}
fetchRolesIfNeeded(serverUrl, member.roles.split(' '));
return {data: created};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchSharedChannels = async (serverUrl: string, teamId: string, page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE) => {
let client: Client;
try {
@@ -599,6 +756,27 @@ export const fetchSharedChannels = async (serverUrl: string, teamId: string, pag
return {error};
}
};
export const makeGroupChannel = async (serverUrl: string, userIds: string[], shouldSwitchToChannel = true) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const currentUserId = await queryCurrentUserId(operator.database);
const result = await createGroupChannel(serverUrl, [currentUserId, ...userIds]);
const channel = result.data;
if (channel && shouldSwitchToChannel) {
switchToChannelById(serverUrl, channel.id);
}
return result;
} catch (error) {
return {error};
}
};
export async function getChannelMemberCountsByGroup(serverUrl: string, channelId: string, includeTimezones: boolean) {
let client: Client;
try {

View File

@@ -7,6 +7,7 @@ import {chunk} from 'lodash';
import {updateChannelsDisplayName} from '@actions/local/channel';
import {updateRecentCustomStatuses, updateLocalUser} from '@actions/local/user';
import {fetchRolesIfNeeded} from '@actions/remote/role';
import {removeUserFromList} from '@app/utils/user';
import {Database, General} from '@constants';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
@@ -248,7 +249,7 @@ export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetc
return {error};
}
if (!userIds.length) {
return {users: []};
return {users: [], existingUsers: []};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
@@ -257,9 +258,15 @@ export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetc
}
try {
const currentUserId = await queryCurrentUserId(operator.database);
const exisingUsers = await queryUsersById(operator.database, userIds);
const usersToLoad = userIds.filter((id) => (id !== currentUserId && !exisingUsers.find((u) => u.id === id)));
const currentUser = await queryCurrentUser(operator.database);
const existingUsers = await queryUsersById(operator.database, userIds);
if (userIds.includes(currentUser!.id)) {
existingUsers.push(currentUser!);
}
const usersToLoad = new Set(userIds.filter((id) => (!existingUsers.find((u) => u.id === id))));
if (usersToLoad.size === 0) {
return {users: [], existingUsers};
}
const users = await client.getProfilesByIds([...new Set(usersToLoad)]);
if (!fetchOnly) {
await operator.handleUsers({
@@ -268,7 +275,7 @@ export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetc
});
}
return {users};
return {users, existingUsers};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
@@ -311,6 +318,103 @@ export const fetchUsersByUsernames = async (serverUrl: string, usernames: string
}
};
export const fetchProfiles = async (serverUrl: string, page = 0, perPage: number = General.PROFILE_CHUNK_SIZE, options: any = {}, fetchOnly = false) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const users = await client.getProfiles(page, perPage, options);
if (!fetchOnly) {
const currentUserId = await queryCurrentUserId(operator.database);
const toStore = removeUserFromList(currentUserId, users);
await operator.handleUsers({
users: toStore,
prepareRecordsOnly: false,
});
}
return {users};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
};
export const fetchProfilesInTeam = async (serverUrl: string, teamId: string, page = 0, perPage: number = General.PROFILE_CHUNK_SIZE, sort = '', options: any = {}, fetchOnly = false) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const users = await client.getProfilesInTeam(teamId, page, perPage, sort, options);
if (!fetchOnly) {
const currentUserId = await queryCurrentUserId(operator.database);
const toStore = removeUserFromList(currentUserId, users);
await operator.handleUsers({
users: toStore,
prepareRecordsOnly: false,
});
}
return {users};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
};
export const searchProfiles = async (serverUrl: string, term: string, options: any = {}, fetchOnly = false) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const currentUserId = await queryCurrentUserId(operator.database);
const users = await client.searchUsers(term, options);
if (!fetchOnly) {
const toStore = removeUserFromList(currentUserId, users);
await operator.handleUsers({
users: toStore,
prepareRecordsOnly: false,
});
}
return {data: users};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
};
export const fetchMissingProfilesByIds = async (serverUrl: string, userIds: string[]) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {

View File

@@ -13,11 +13,10 @@ import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import WebsocketManager from '@init/websocket_manager';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {queryCommonSystemValues} from '@queries/servers/system';
import {queryCurrentUser, queryUserById} from '@queries/servers/user';
import {queryCurrentUser} from '@queries/servers/user';
import {displayUsername} from '@utils/user';
import type ChannelModel from '@typings/database/models/servers/channel';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {CHANNEL, CHANNEL_MEMBERSHIP}} = MM_TABLES;
@@ -94,11 +93,9 @@ export async function handleUserTypingEvent(serverUrl: string, msg: WebSocketMes
const {config, license} = await queryCommonSystemValues(database);
let user: UserModel | UserProfile | undefined = await queryUserById(database, msg.data.user_id);
if (!user) {
const {users} = await fetchUsersByIds(serverUrl, [msg.data.user_id]);
user = users?.[0];
}
const {users, existingUsers} = await fetchUsersByIds(serverUrl, [msg.data.user_id]);
const user = users?.[0] || existingUsers?.[0];
const namePreference = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(namePreference, config, license);
const currentUser = await queryCurrentUser(database);

View File

@@ -3,7 +3,7 @@
import React, {useCallback, useEffect} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import {Platform, Text, View} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
@@ -101,7 +101,7 @@ const ChannelListHeader = ({canCreateChannels, canJoinChannels, displayName, ico
bottomSheet({
closeButtonId,
renderContent,
snapPoints: [(items * ITEM_HEIGHT) + (insets.bottom * 2), 10],
snapPoints: [((items + Platform.select({android: 1, default: 0})) * ITEM_HEIGHT) + (insets.bottom * 2), 10],
theme,
title: intl.formatMessage({id: 'home.header.plus_menu', defaultMessage: 'Options'}),
});

View File

@@ -36,7 +36,13 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels}: Props) => {
}, [intl, theme]);
const openDirectMessage = useCallback(async () => {
// To be added
await dismissBottomSheet();
const title = intl.formatMessage({id: 'create_direct_message.title', defaultMessage: 'Create Direct Message'});
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
showModal(Screens.CREATE_DIRECT_MESSAGE, title, {
closeButton,
});
}, [intl, theme]);
return (

View File

@@ -15,7 +15,7 @@ import type {Client} from '@client/rest';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
author?: UserModel;
author?: UserModel | UserProfile;
iconSize?: number;
size: number;
source?: Source | string;
@@ -58,7 +58,8 @@ const Image = ({author, iconSize, size, source}: Props) => {
}
if (author && client) {
const pictureUrl = client.getProfilePictureUrl(author.id, author.lastPictureUpdate);
const lastPictureUpdate = ('lastPictureUpdate' in author) ? author.lastPictureUpdate : author.last_picture_update;
const pictureUrl = client.getProfilePictureUrl(author.id, lastPictureUpdate);
const imgSource = source ?? {uri: `${serverUrl}${pictureUrl}`};
return (
<FastImage

View File

@@ -21,7 +21,7 @@ const STATUS_BUFFER = Platform.select({
});
type ProfilePictureProps = {
author?: UserModel;
author?: UserModel | UserProfile;
iconSize?: number;
showStatus?: boolean;
size: number;
@@ -73,9 +73,10 @@ const ProfilePicture = ({
const style = getStyleSheet(theme);
const buffer = showStatus ? STATUS_BUFFER || 0 : 0;
const isBot = author && (('isBot' in author) ? author.isBot : author.is_bot);
useEffect(() => {
if (author && !author.status && showStatus) {
if (!isBot && author && !author.status && showStatus) {
fetchStatusInBatch(serverUrl, author.id);
}
}, []);

View File

@@ -10,7 +10,7 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
author?: UserModel;
author?: UserModel | UserProfile;
statusSize: number;
statusStyle?: StyleProp<ViewProps>;
theme: Theme;
@@ -39,8 +39,8 @@ const Status = ({author, statusSize, statusStyle, theme}: Props) => {
statusStyle,
{borderRadius: statusSize / 2},
]), [statusStyle]);
if (author?.status && !author.isBot) {
const isBot = author && (('isBot' in author) ? author.isBot : author.is_bot);
if (author?.status && !isBot) {
return (
<View
style={containerStyle}

View File

@@ -0,0 +1,218 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {
Text,
View,
} from 'react-native';
import CompassIcon from '@components/compass_icon';
import ProfilePicture from '@components/profile_picture';
import {BotTag, GuestTag} from '@components/tag';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {displayUsername, isGuest} from '@utils/user';
import TouchableWithFeedback from '../touchable_with_feedback';
type Props = {
id: string;
isMyUser: boolean;
user: UserProfile;
teammateNameDisplay: string;
testID: string;
onPress?: (user: UserProfile) => void;
selectable: boolean;
selected: boolean;
enabled: boolean;
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
flexDirection: 'row',
paddingHorizontal: 15,
overflow: 'hidden',
},
profileContainer: {
flexDirection: 'row',
alignItems: 'center',
color: theme.centerChannelColor,
},
textContainer: {
marginLeft: 10,
justifyContent: 'center',
flexDirection: 'column',
flex: 1,
},
displayName: {
fontSize: 15,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
username: {
fontSize: 15,
color: theme.centerChannelColor,
},
indicatorContainer: {
flexDirection: 'row',
},
deactivated: {
marginTop: 2,
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
sharedUserIcon: {
alignSelf: 'center',
opacity: 0.75,
},
selector: {
height: 28,
width: 28,
borderRadius: 14,
borderWidth: 3,
borderColor: changeOpacity(theme.centerChannelColor, 0.32),
alignItems: 'center',
justifyContent: 'center',
},
selectorContainer: {
height: 50,
paddingRight: 10,
alignItems: 'center',
justifyContent: 'center',
},
selectorDisabled: {
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
},
selectorFilled: {
backgroundColor: theme.sidebarBg,
borderWidth: 0,
},
};
});
export default function UserListRow({
id,
isMyUser,
user,
teammateNameDisplay,
testID,
onPress,
selectable,
selected,
enabled,
}: Props) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
const intl = useIntl();
const {formatMessage} = intl;
const {username} = user;
const handlePress = useCallback(() => {
if (onPress) {
onPress(user);
}
}, [onPress, user]);
const iconStyle = useMemo(() => {
return [style.selector, (selected && style.selectorFilled), (!enabled && style.selectorDisabled)];
}, [style, selected, enabled]);
const Icon = () => {
return (
<View style={style.selectorContainer}>
<View style={iconStyle}>
{selected &&
<CompassIcon
name='check'
size={24}
color={theme.sidebarText}
/>
}
</View>
</View>
);
};
let usernameDisplay = `@${username}`;
if (isMyUser) {
usernameDisplay = formatMessage({
id: 'mobile.create_direct_message.you',
defaultMessage: '@{username} - you',
}, {username});
}
const teammateDisplay = displayUsername(user, intl.locale, teammateNameDisplay);
const showTeammateDisplay = teammateDisplay !== username;
const itemTestID = `${testID}.${id}`;
const displayUsernameTestID = `${testID}.display_username`;
const profilePictureTestID = `${itemTestID}.profile_picture`;
return (
<TouchableWithFeedback
onPress={handlePress}
>
<View style={style.container}>
<View style={style.profileContainer}>
<ProfilePicture
author={user}
size={32}
iconSize={24}
testID={profilePictureTestID}
/>
</View>
<View
style={style.textContainer}
testID={itemTestID}
>
<View>
<View style={style.indicatorContainer}>
<Text
style={style.username}
ellipsizeMode='tail'
numberOfLines={1}
testID={displayUsernameTestID}
>
{usernameDisplay}
</Text>
<BotTag
show={Boolean(user.is_bot)}
/>
<GuestTag
show={isGuest(user.roles)}
/>
</View>
</View>
{showTeammateDisplay &&
<View>
<Text
style={style.displayName}
ellipsizeMode='tail'
numberOfLines={1}
>
{teammateDisplay}
</Text>
</View>
}
{user.delete_at > 0 &&
<View>
<Text
style={style.deactivated}
>
{formatMessage({id: 'mobile.user_list.deactivated', defaultMessage: 'Deactivated'})}
</Text>
</View>
}
</View>
{selectable &&
<Icon/>
}
</View>
</TouchableWithFeedback>
);
}

View File

@@ -32,4 +32,9 @@ export default {
DISABLED: 'disabled',
DEFAULT_ON: 'default_on',
DEFAULT_OFF: 'default_off',
PROFILE_CHUNK_SIZE: 100,
SEARCH_TIMEOUT_MILLISECONDS: 100,
AUTOCOMPLETE_SPLIT_CHARACTERS: ['.', '-', '_'],
CHANNEL_USER_ROLE: 'channel_user',
RESTRICT_DIRECT_MESSAGE_ANY: 'any',
};

View File

@@ -23,6 +23,7 @@ export const IN_APP_NOTIFICATION = 'InAppNotification';
export const LOGIN = 'Login';
export const MENTIONS = 'Mentions';
export const MFA = 'MFA';
export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage';
export const PERMALINK = 'Permalink';
export const SEARCH = 'Search';
export const SERVER = 'Server';
@@ -55,6 +56,7 @@ export default {
LOGIN,
MENTIONS,
MFA,
CREATE_DIRECT_MESSAGE,
PERMALINK,
SEARCH,
SERVER,

View File

@@ -0,0 +1,407 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, View} from 'react-native';
import {Navigation} from 'react-native-navigation';
import {SafeAreaView} from 'react-native-safe-area-context';
import {makeDirectChannel, makeGroupChannel} from '@actions/remote/channel';
import {fetchProfiles, fetchProfilesInTeam, searchProfiles} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import SearchBar from '@components/search_bar';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import {t} from '@i18n';
import {dismissModal, setButtons} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft';
import {
changeOpacity,
makeStyleSheetFromTheme,
getKeyboardAppearanceFromTheme,
} from '@utils/theme';
import {displayUsername, filterProfilesMatchingTerm} from '@utils/user';
import SelectedUsers from './selected_users';
import UserList from './user_list';
const START_BUTTON = 'start-conversation';
const CLOSE_BUTTON = 'close-dms';
type Props = {
componentId: string;
currentTeamId: string;
currentUserId: string;
restrictDirectMessage: boolean;
teammateNameDisplay: string;
}
const close = () => {
Keyboard.dismiss();
dismissModal();
};
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
},
searchBar: {
marginHorizontal: 12,
borderRadius: 8,
marginTop: 12,
marginBottom: 12,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
},
searchBarInput: {
color: theme.centerChannelColor,
},
loadingContainer: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 70,
justifyContent: 'center',
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
noResultContainer: {
flexGrow: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
fontSize: 26,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
export default function CreateDirectMessage({
componentId,
currentTeamId,
currentUserId,
restrictDirectMessage,
teammateNameDisplay,
}: Props) {
const serverUrl = useServerUrl();
const theme = useTheme();
const style = getStyleFromTheme(theme);
const intl = useIntl();
const {formatMessage} = intl;
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
const next = useRef(true);
const page = useRef(-1);
const mounted = useRef(false);
const [profiles, setProfiles] = useState<UserProfile[]>([]);
const [searchResults, setSearchResults] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(false);
const [term, setTerm] = useState('');
const [startingConversation, setStartingConversation] = useState(false);
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
const selectedCount = Object.keys(selectedIds).length;
const isSearch = Boolean(term);
const addToProfiles = useRef((newProfiles: UserProfile[]) => setProfiles([...profiles, ...newProfiles]));
useEffect(() => {
addToProfiles.current = (newProfiles: UserProfile[]) => setProfiles([...profiles, ...newProfiles]);
}, [profiles]);
const loadedProfiles = ({users}: {users?: UserProfile[]}) => {
if (mounted.current) {
if (users && !users.length) {
next.current = false;
}
page.current += 1;
setLoading(false);
if (users && users.length) {
addToProfiles.current(users);
}
}
};
const clearSearch = useCallback(() => {
setTerm('');
setSearchResults([]);
}, []);
const getProfiles = useCallback(debounce(() => {
if (next.current && !loading && !term && mounted.current) {
setLoading(true);
if (restrictDirectMessage) {
fetchProfilesInTeam(serverUrl, currentTeamId, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
} else {
fetchProfiles(serverUrl, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
}
}
}, 100), [loading, isSearch, restrictDirectMessage, serverUrl, currentTeamId]);
const handleRemoveProfile = useCallback((id: string) => {
const newSelectedIds = Object.assign({}, selectedIds);
Reflect.deleteProperty(newSelectedIds, id);
setSelectedIds(newSelectedIds);
}, [selectedIds]);
const createDirectChannel = useCallback(async (id: string): Promise<boolean> => {
const user = profiles.find((u) => u.id === 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,
},
);
}
return !result.error;
}, [profiles, intl.locale, teammateNameDisplay, serverUrl]);
const createGroupChannel = useCallback(async (ids: string[]): Promise<boolean> => {
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.",
},
);
}
return !result.error;
}, [serverUrl]);
const startConversation = useCallback(async (selectedId?: {[id: string]: boolean}) => {
if (startingConversation) {
return;
}
setStartingConversation(true);
const idsToUse = selectedId ? Object.keys(selectedId) : Object.keys(selectedIds);
let success;
if (idsToUse.length === 0) {
success = false;
} else if (idsToUse.length > 1) {
success = await createGroupChannel(idsToUse);
} else {
success = await createDirectChannel(idsToUse[0]);
}
if (success) {
close();
} else {
setStartingConversation(false);
}
}, [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,
};
startConversation(selectedId);
} else {
const wasSelected = selectedIds[user.id];
if (!wasSelected && selectedCount >= General.MAX_USERS_IN_GM - 1) {
return;
}
const newSelectedIds = Object.assign({}, selectedIds);
if (!wasSelected) {
newSelectedIds[user.id] = user;
}
setSelectedIds(newSelectedIds);
clearSearch();
}
}, [selectedIds, currentUserId, handleRemoveProfile, startConversation, clearSearch]);
const searchUsers = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
setLoading(true);
let results;
if (restrictDirectMessage) {
results = await searchProfiles(serverUrl, lowerCasedTerm);
} else {
results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: currentTeamId});
}
let data: UserProfile[] = [];
if (results.data) {
data = results.data;
}
setSearchResults(data);
setLoading(false);
}, [restrictDirectMessage, serverUrl, currentTeamId]);
const onSearch = useCallback((text: string) => {
if (text) {
setTerm(text);
if (searchTimeoutId.current) {
clearTimeout(searchTimeoutId.current);
}
searchTimeoutId.current = setTimeout(() => {
searchUsers(text);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
} else {
clearSearch();
}
}, [searchUsers, clearSearch]);
const updateNavigationButtons = useCallback(async (startEnabled: boolean) => {
const closeIcon = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
setButtons(componentId, {
leftButtons: [{
id: CLOSE_BUTTON,
icon: closeIcon,
testID: 'close.more_direct_messages.button',
}],
rightButtons: [{
color: theme.sidebarHeaderTextColor,
id: START_BUTTON,
text: formatMessage({id: 'mobile.create_direct_message.start', defaultMessage: 'Start'}),
showAsAction: 'always',
enabled: startEnabled,
testID: 'more_direct_messages.start.button',
}],
});
}, [intl.locale, theme]);
useEffect(() => {
const unsubscribe = Navigation.events().registerComponentListener({navigationButtonPressed: ({buttonId}) => {
if (buttonId === START_BUTTON) {
startConversation();
} else if (buttonId === CLOSE_BUTTON) {
close();
}
}}, componentId);
return () => {
unsubscribe.remove();
};
}, [startConversation]);
useEffect(() => {
mounted.current = true;
updateNavigationButtons(false);
getProfiles();
return () => {
mounted.current = false;
};
}, []);
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]);
if (startingConversation) {
return (
<View style={style.container}>
<Loading color={theme.centerChannelColor}/>
</View>
);
}
return (
<SafeAreaView style={style.container}>
<View style={style.searchBar}>
<SearchBar
testID='more_direct_messages.search_bar'
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
backgroundColor='transparent'
inputHeight={33}
inputStyle={style.searchBarInput}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorSearch={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorDelete={changeOpacity(theme.centerChannelColor, 0.5)}
titleCancelColor={theme.centerChannelColor}
onChangeText={onSearch}
onSearchButtonPress={onSearch}
onCancelButtonPress={clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
{selectedCount > 0 &&
<SelectedUsers
selectedIds={selectedIds}
warnCount={5}
maxCount={7}
onRemove={handleRemoveProfile}
teammateNameDisplay={teammateNameDisplay}
/>
}
<UserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
isSearch={isSearch}
loading={loading}
profiles={data}
selectedIds={selectedIds}
showNoResults={!loading && page.current !== -1}
teammateNameDisplay={teammateNameDisplay}
fetchMore={getProfiles}
/>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';
import {General, Preferences} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import CreateDirectMessage from './create_direct_message';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PreferenceModel from '@typings/database/models/servers/preference';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {SYSTEM, PREFERENCE}} = MM_TABLES;
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const config = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG);
const license = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE);
const preferences = database.get<PreferenceModel>(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe();
const currentUserId = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap(({value}) => of$(value)),
);
const currentTeamId = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe(
switchMap(({value}) => of$(value)),
);
const teammateNameDisplay = combineLatest([config, license, preferences]).pipe(
map(
([{value: cfg}, {value: lcs}, prefs]) => getTeammateNameDisplaySetting(prefs, cfg, lcs),
),
);
const restrictDirectMessage = config.pipe(
switchMap(({value: cfg}) => of$(cfg.RestrictDirectMessage !== General.RESTRICT_DIRECT_MESSAGE_ANY)),
);
return {
teammateNameDisplay,
currentUserId,
restrictDirectMessage,
currentTeamId,
};
});
export default withDatabase(enhanced(CreateDirectMessage));

View File

@@ -0,0 +1,138 @@
// 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='more_direct_messages.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>
);
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {
Text,
TouchableOpacity,
View,
} from 'react-native';
import {typography} from '@app/utils/typography';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {displayUsername} from '@utils/user';
type Props = {
/*
* How to display the names of users.
*/
teammateNameDisplay: string;
/*
* The user that this component represents.
*/
user: UserProfile;
/*
* A handler function that will deselect a user when clicked on.
*/
onRemove: (id: string) => void;
/*
* The test ID.
*/
testID?: string;
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
alignItems: 'center',
flexDirection: 'row',
height: 32,
borderRadius: 16,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
marginBottom: 8,
marginRight: 10,
paddingLeft: 12,
paddingVertical: 8,
paddingRight: 7,
},
remove: {
marginLeft: 7,
},
text: {
color: theme.centerChannelColor,
textAlignVertical: 'center',
height: 32,
...typography('Body', 100, 'SemiBold'),
},
};
});
export default function SelectedUser({
teammateNameDisplay,
user,
onRemove,
testID,
}: Props) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
const intl = useIntl();
const onPress = useCallback(() => {
onRemove(user.id);
}, [onRemove, user.id]);
return (
<View
style={style.container}
testID={`${testID}.${user.id}`}
>
<Text
style={style.text}
testID={`${testID}.${user.id}.display_username`}
>
{displayUsername(user, intl.locale, teammateNameDisplay)}
</Text>
<TouchableOpacity
style={style.remove}
onPress={onPress}
testID={`${testID}.${user.id}.remove.button`}
>
<CompassIcon
name='close-circle'
size={17}
color={changeOpacity(theme.centerChannelColor, 0.32)}
/>
</TouchableOpacity>
</View>
);
}

View File

@@ -0,0 +1,275 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {FlatList, Keyboard, ListRenderItemInfo, Platform, SectionList, SectionListData, Text, View} from 'react-native';
import {typography} from '@app/utils/typography';
import FormattedText from '@components/formatted_text';
import UserListRow from '@components/user_list_row';
import {General} from '@constants';
import {useTheme} from '@context/theme';
import {
changeOpacity,
makeStyleSheetFromTheme,
} from '@utils/theme';
const INITIAL_BATCH_TO_RENDER = 15;
const SCROLL_EVENT_THROTTLE = 60;
const keyboardDismissProp = Platform.select({
android: {
onScrollBeginDrag: Keyboard.dismiss,
},
ios: {
keyboardDismissMode: 'on-drag' as const,
},
});
const keyExtractor = (item: UserProfile) => {
return item.id;
};
const sectionKeyExtractor = (profile: UserProfile) => {
// Group items alphabetically by first letter of username
return profile.username[0].toUpperCase();
};
export function createProfilesSections(profiles: UserProfile[]) {
const sections: {[key: string]: UserProfile[]} = {};
const sectionKeys: string[] = [];
for (const profile of profiles) {
const sectionKey = sectionKeyExtractor(profile);
if (!sections[sectionKey]) {
sections[sectionKey] = [];
sectionKeys.push(sectionKey);
}
sections[sectionKey].push(profile);
}
sectionKeys.sort();
return sectionKeys.map((sectionKey) => {
return {
id: sectionKey,
data: sections[sectionKey],
};
});
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
list: {
backgroundColor: theme.centerChannelBg,
flex: 1,
...Platform.select({
android: {
marginBottom: 20,
},
}),
},
container: {
flexGrow: 1,
},
separator: {
height: 1,
flex: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
},
listView: {
flex: 1,
backgroundColor: theme.centerChannelBg,
...Platform.select({
android: {
marginBottom: 20,
},
}),
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
searching: {
backgroundColor: theme.centerChannelBg,
height: '100%',
position: 'absolute',
width: '100%',
},
sectionContainer: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
paddingLeft: 10,
paddingVertical: 2,
height: 28,
},
sectionWrapper: {
backgroundColor: theme.centerChannelBg,
},
sectionText: {
color: theme.centerChannelColor,
...typography('Body', 300, 'SemiBold'),
},
noResultContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
fontSize: 26,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
type Props = {
profiles: UserProfile[];
currentUserId: string;
teammateNameDisplay: string;
handleSelectProfile: (user: UserProfile) => void;
fetchMore: () => void;
loading: boolean;
showNoResults: boolean;
selectedIds: {[id: string]: UserProfile};
testID?: string;
isSearch: boolean;
}
export default function UserList({
profiles,
selectedIds,
currentUserId,
teammateNameDisplay,
handleSelectProfile,
fetchMore,
loading,
showNoResults,
isSearch,
testID,
}: Props) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
const renderItem = useCallback(({item}: ListRenderItemInfo<UserProfile>) => {
// The list will re-render when the selection changes because it's passed into the list as extraData
const selected = Boolean(selectedIds[item.id]);
const canAdd = Object.keys(selectedIds).length < General.MAX_USERS_IN_GM;
return (
<UserListRow
key={item.id}
id={item.id}
isMyUser={currentUserId === item.id}
onPress={handleSelectProfile}
selectable={canAdd}
selected={selected}
enabled={canAdd}
testID='more_direct_messages.user_list.user_item'
teammateNameDisplay={teammateNameDisplay}
user={item}
/>
);
}, [selectedIds, currentUserId, handleSelectProfile, teammateNameDisplay]);
const renderLoading = useCallback(() => {
if (!loading) {
return null;
}
return (
<View style={style.loadingContainer}>
<FormattedText
id='mobile.loading_members'
defaultMessage='Loading Members...'
style={style.loadingText}
/>
</View>
);
}, [loading && style]);
const renderNoResults = useCallback(() => {
if (!showNoResults) {
return null;
}
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
}, [showNoResults && style]);
const renderSectionHeader = useCallback(({section}: {section: SectionListData<UserProfile>}) => {
return (
<View style={style.sectionWrapper}>
<View style={style.sectionContainer}>
<Text style={style.sectionText}>{section.id}</Text>
</View>
</View>
);
}, [style]);
const renderFlatList = (data: UserProfile[]) => {
return (
<FlatList
contentContainerStyle={style.container}
data={data}
extraData={selectedIds}
keyboardShouldPersistTaps='always'
{...keyboardDismissProp}
keyExtractor={keyExtractor}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
ListEmptyComponent={renderNoResults}
ListFooterComponent={renderLoading}
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
removeClippedSubviews={true}
renderItem={renderItem}
scrollEventThrottle={SCROLL_EVENT_THROTTLE}
style={style.list}
testID={testID}
/>
);
};
const renderSectionList = (data: Array<SectionListData<UserProfile>>) => {
return (
<SectionList
contentContainerStyle={style.container}
extraData={loading ? false : selectedIds}
keyboardShouldPersistTaps='always'
{...keyboardDismissProp}
keyExtractor={keyExtractor}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
ListEmptyComponent={renderNoResults}
ListFooterComponent={renderLoading}
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
removeClippedSubviews={true}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
scrollEventThrottle={SCROLL_EVENT_THROTTLE}
sections={data}
style={style.list}
stickySectionHeadersEnabled={false}
testID={testID}
onEndReached={fetchMore}
/>
);
};
const data = useMemo(() => {
if (isSearch) {
return profiles;
}
return createProfilesSections(profiles);
}, [isSearch, profiles]);
if (isSearch) {
return renderFlatList(data as UserProfile[]);
}
return renderSectionList(data as Array<SectionListData<UserProfile>>);
}

View File

@@ -110,6 +110,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.SSO:
screen = withIntl(require('@screens/sso').default);
break;
case Screens.CREATE_DIRECT_MESSAGE:
screen = withServerDatabase((require('@screens/create_direct_message').default));
break;
case Screens.THREAD:
screen = withServerDatabase(require('@screens/thread').default);
break;

View File

@@ -4,7 +4,7 @@
import moment from 'moment-timezone';
import {Alert} from 'react-native';
import {Permissions, Preferences} from '@constants';
import {General, Permissions, Preferences} from '@constants';
import {CustomStatusDuration} from '@constants/custom_status';
import {UserModel} from '@database/models/server';
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
@@ -191,3 +191,89 @@ export function confirmOutOfOfficeDisabled(intl: IntlShape, status: string, upda
}],
);
}
export function isShared(user: UserProfile): boolean {
return Boolean(user.remote_id);
}
export function removeUserFromList(userId: string, originalList: UserProfile[]): UserProfile[] {
const list = [...originalList];
for (let i = list.length - 1; i >= 0; i--) {
if (list[i].id === userId) {
list.splice(i, 1);
return list;
}
}
return list;
}
// Splits the term by a splitStr and composes a list of the parts of
// the split concatenated with the rest, forming a set of suggesitons
// matchable with startsWith
//
// E.g.: for "one.two.three" by "." it would yield
// ["one.two.three", ".two.three", "two.three", ".three", "three"]
export function getSuggestionsSplitBy(term: string, splitStr: string): string[] {
const splitTerm = term.split(splitStr);
const initialSuggestions = splitTerm.map((st, i) => splitTerm.slice(i).join(splitStr));
let suggestions: string[] = [];
if (splitStr === ' ') {
suggestions = initialSuggestions;
} else {
suggestions = initialSuggestions.reduce((acc, val) => {
if (acc.length === 0) {
acc.push(val);
} else {
acc.push(splitStr + val, val);
}
return acc;
}, [] as string[]);
}
return suggestions;
}
export function getSuggestionsSplitByMultiple(term: string, splitStrs: string[]): string[] {
const suggestions = splitStrs.reduce((acc, val) => {
getSuggestionsSplitBy(term, val).forEach((suggestion) => acc.add(suggestion));
return acc;
}, new Set<string>());
return [...suggestions];
}
export function filterProfilesMatchingTerm(users: UserProfile[], term: string): UserProfile[] {
const lowercasedTerm = term.toLowerCase();
let trimmedTerm = lowercasedTerm;
if (trimmedTerm.startsWith('@')) {
trimmedTerm = trimmedTerm.substring(1);
}
return users.filter((user: UserProfile) => {
if (!user) {
return false;
}
const profileSuggestions: string[] = [];
const usernameSuggestions = getSuggestionsSplitByMultiple((user.username || '').toLowerCase(), General.AUTOCOMPLETE_SPLIT_CHARACTERS);
profileSuggestions.push(...usernameSuggestions);
const first = (user.first_name || '').toLowerCase();
const last = (user.last_name || '').toLowerCase();
const full = first + ' ' + last;
profileSuggestions.push(first, last, full);
profileSuggestions.push((user.nickname || '').toLowerCase());
const email = (user.email || '').toLowerCase();
profileSuggestions.push(email);
profileSuggestions.push((user.nickname || '').toLowerCase());
const split = email.split('@');
if (split.length > 1) {
profileSuggestions.push(split[1]);
}
return profileSuggestions.
filter((suggestion) => suggestion !== '').
some((suggestion) => suggestion.startsWith(trimmedTerm));
});
}

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "Diese Gruppennachricht hat Gäste",
"channel.isGuest": "Diese Person ist ein Gast",
"channel_header.addMembers": "Mitglieder hinzufügen",
"channel_header.directchannel.you": "{displayname} (Sie) ",
"channel_header.directchannel.you": "{displayName} (Sie) ",
"channel_header.manageMembers": "Mitglieder verwalten",
"channel_header.notificationPreference": "Mobile Push-Benachrichtigungen",
"channel_header.notificationPreference.all": "Alle",

View File

@@ -75,6 +75,7 @@
"combined_system_message.removed_from_team.one_you": "You were **removed from the team**.",
"combined_system_message.removed_from_team.two": "{firstUser} and {secondUser} were **removed from the team**.",
"combined_system_message.you": "You",
"create_direct_message.title": "Create Direct Message",
"create_post.deactivated": "You are viewing an archived channel with a deactivated user.",
"create_post.thread_reply": "Reply to this thread...",
"create_post.write": "Write to {channelDisplayName}",
@@ -210,6 +211,11 @@
"mobile.components.select_server_view.proceed": "Proceed",
"mobile.create_channel": "Create",
"mobile.create_channel.public": "New Public 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.you": "@{username} - you",
"mobile.create_post.read_only": "This channel is read-only.",
"mobile.custom_list.no_results": "No Results",
"mobile.custom_status.choose_emoji": "Choose an emoji",
@@ -238,6 +244,7 @@
"mobile.join_channel.error": "We couldn't join the channel {displayName}. Please check your connection and try again.",
"mobile.link.error.text": "Unable to open the link.",
"mobile.link.error.title": "Error",
"mobile.loading_members": "Loading Members...",
"mobile.login_options.cant_heading": "Can't Log In",
"mobile.login_options.enter_credentials": "Enter your login details below.",
"mobile.login_options.gitlab": "GitLab",
@@ -277,6 +284,7 @@
"mobile.oauth.switch_to_browser.error_title": "Sign in error",
"mobile.oauth.switch_to_browser.title": "Redirecting...",
"mobile.oauth.try_again": "Try again",
"mobile.open_gm.error": "",
"mobile.permission_denied_dismiss": "Don't Allow",
"mobile.permission_denied_retry": "Settings",
"mobile.post_info.add_reaction": "Add Reaction",
@@ -359,6 +367,7 @@
"mobile.unsupported_server.message": "Attachments, link previews, reactions and embed data may not be displayed correctly. If this issue persists contact your System Administrator to upgrade your Mattermost server.",
"mobile.unsupported_server.ok": "OK",
"mobile.unsupported_server.title": "Unsupported server version",
"mobile.user_list.deactivated": "Deactivated",
"mobile.write_storage_permission_denied_description": "Save files to your device. Open Settings to grant {applicationName} write access to files on this device.",
"mobile.youtube_playback_error.description": "An error occurred while trying to play the YouTube video.\nDetails: {details}",
"mobile.youtube_playback_error.title": "YouTube playback error",
@@ -429,11 +438,16 @@
"status_dropdown.set_offline": "Offline",
"status_dropdown.set_online": "Online",
"status_dropdown.set_ooo": "Out Of Office",
"suggestion.mention.channels": "",
"suggestion.mention.morechannels": "",
"suggestion.search.direct": "",
"suggestion.search.private": "",
"suggestion.search.public": "",
"team_list.no_other_teams.description": "To join another team, ask a Team Admin for an invitation, or create your own team.",
"team_list.no_other_teams.title": "No additional teams to join",
"thread.header.thread": "Thread",
"thread.header.thread_in": "in {channelName}",
"thread.header.thread_dm": "Direct Message Thread",
"thread.header.thread_in": "in {channelName}",
"thread.noReplies": "No replies yet",
"thread.repliesCount": "{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}",
"threads.followMessage": "Follow Message",

View File

@@ -58,7 +58,7 @@
"channel.hasGuests": "This group message has guests",
"channel.isGuest": "This person is a guest",
"channel_header.addMembers": "Add Members",
"channel_header.directchannel.you": "{displayname} (you) ",
"channel_header.directchannel.you": "{displayName} (you) ",
"channel_header.manageMembers": "Manage Members",
"channel_header.notificationPreference": "Mobile Notifications",
"channel_header.notificationPreference.all": "All",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "Este grupo tiene huéspedes",
"channel.isGuest": "Esta persona es un huésped",
"channel_header.addMembers": "Agregar Miembros",
"channel_header.directchannel.you": "{displayname} (tu) ",
"channel_header.directchannel.you": "{displayName} (tu) ",
"channel_header.manageMembers": "Administrar Miembros",
"channel_header.notificationPreference": "Notificaciones Móviles",
"channel_header.notificationPreference.all": "Todos",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "Ce groupe dispose d'utilisateurs invités",
"channel.isGuest": "Cet utilisateur est un utilisateur invité",
"channel_header.addMembers": "Ajouter Membres",
"channel_header.directchannel.you": "{displayname} (vous) ",
"channel_header.directchannel.you": "{displayName} (vous) ",
"channel_header.manageMembers": "Gérer les membres",
"channel_header.notificationPreference": "Notifications push mobiles",
"channel_header.notificationPreference.all": "Tous",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "Questo messaggio di gruppo ha degli ospiti",
"channel.isGuest": "Questa persona è un ospite",
"channel_header.addMembers": "Aggiungi membri",
"channel_header.directchannel.you": "{displayname} (tu) ",
"channel_header.directchannel.you": "{displayName} (tu) ",
"channel_header.manageMembers": "Gestione membri",
"channel_header.notificationPreference": "Notifiche push mobile",
"channel_header.notificationPreference.all": "Tutti",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "このグループメッセージにはゲストがいます",
"channel.isGuest": "このユーザーはゲストです",
"channel_header.addMembers": "メンバーを追加する",
"channel_header.directchannel.you": "{displayname} (あなた) ",
"channel_header.directchannel.you": "{displayName} (あなた) ",
"channel_header.manageMembers": "メンバー管理",
"channel_header.notificationPreference": "モバイルプッシュ通知",
"channel_header.notificationPreference.all": "すべて",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "이 그룹 메시지는 게스트가 있습니다.",
"channel.isGuest": "이 사람은 게스트입니다.",
"channel_header.addMembers": "멤버 추가",
"channel_header.directchannel.you": "{displayname} (당신) ",
"channel_header.directchannel.you": "{displayName} (당신) ",
"channel_header.manageMembers": "회원 관리",
"channel_header.notificationPreference": "모바일 푸시 알림",
"channel_header.notificationPreference.all": "모두",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "Dit groepsbericht heeft gasten",
"channel.isGuest": "Deze persoon is een gast",
"channel_header.addMembers": "Leden toevoegen",
"channel_header.directchannel.you": "{displayname} (jij) ",
"channel_header.directchannel.you": "{displayName} (jij) ",
"channel_header.manageMembers": "Leden beheren",
"channel_header.notificationPreference": "Mobiele push notificaties",
"channel_header.notificationPreference.all": "Allen",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "Ten czat grupowy zawiera gości.",
"channel.isGuest": "Ta osoba jest gościem",
"channel_header.addMembers": "Dodaj Użytkowników",
"channel_header.directchannel.you": "{displayname} (Ty) ",
"channel_header.directchannel.you": "{displayName} (Ty) ",
"channel_header.manageMembers": "Zarządzaj użytkownikami",
"channel_header.notificationPreference": "Powiadomienia mobilne push",
"channel_header.notificationPreference.all": "Wszystko",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "Este grupo de mensagem tem convidados",
"channel.isGuest": "Esta pessoa é um convidado",
"channel_header.addMembers": "Adicionar Membros",
"channel_header.directchannel.you": "{displayname} (você) ",
"channel_header.directchannel.you": "{displayName} (você) ",
"channel_header.manageMembers": "Gerenciar Membros",
"channel_header.notificationPreference": "Notificações Móvel",
"channel_header.notificationPreference.all": "Todos",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "Acest mesaj de grup are oaspeți",
"channel.isGuest": "Această persoană este invitat",
"channel_header.addMembers": "Adăugați membri",
"channel_header.directchannel.you": "{displayname} (tine) ",
"channel_header.directchannel.you": "{displayName} (tine) ",
"channel_header.manageMembers": "Gestioneaza membri",
"channel_header.notificationPreference": "Notificări pe mobil",
"channel_header.notificationPreference.all": "Toate",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "В этом групповом сообщении есть гости",
"channel.isGuest": "Этот человек гость",
"channel_header.addMembers": "Добавить участников",
"channel_header.directchannel.you": "{displayname} (это вы) ",
"channel_header.directchannel.you": "{displayName} (это вы) ",
"channel_header.manageMembers": "Управление участниками",
"channel_header.notificationPreference": "Мобильные уведомления",
"channel_header.notificationPreference.all": "Все",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "Bu grup iletisinde konuklar var",
"channel.isGuest": "Bu kişi bir konuk",
"channel_header.addMembers": "Üye Ekle",
"channel_header.directchannel.you": "{displayname} (siz) ",
"channel_header.directchannel.you": "{displayName} (siz) ",
"channel_header.manageMembers": "Üye Yönetimi",
"channel_header.notificationPreference": "Mobil Bildirimler",
"channel_header.notificationPreference.all": "Tümü",

View File

@@ -17,7 +17,7 @@
"channel.channelHasGuests": "This channel has guests",
"channel.hasGuests": "This group message has guests",
"channel_header.addMembers": "Додати учасників",
"channel_header.directchannel.you": "{displayname} (ви)",
"channel_header.directchannel.you": "{displayName} (ви)",
"channel_header.manageMembers": "Управління учасниками",
"channel_header.notificationPreference": "Надіслати sms повідомлення",
"channel_header.notificationPreference.all": "All",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "此群消息有游客",
"channel.isGuest": "该用户是访客",
"channel_header.addMembers": "添加成员",
"channel_header.directchannel.you": "{displayname} (您) ",
"channel_header.directchannel.you": "{displayName} (您) ",
"channel_header.manageMembers": "成员管理",
"channel_header.notificationPreference": "移动设备推送",
"channel_header.notificationPreference.all": "全部",

View File

@@ -18,7 +18,7 @@
"channel.hasGuests": "此群組訊息有訪客",
"channel.isGuest": "這個使用者是訪客",
"channel_header.addMembers": "新增成員",
"channel_header.directchannel.you": "{displayname} (你) ",
"channel_header.directchannel.you": "{displayName} (你) ",
"channel_header.manageMembers": "成員管理",
"channel_header.notificationPreference": "行動推播",
"channel_header.notificationPreference.all": "全部",

View File

@@ -41,6 +41,7 @@ type UserProfile = {
timezone?: UserTimezone;
is_bot: boolean;
last_picture_update: number;
remote_id?: string;
status?: string;
};