From b27ebce2e0382542de6411c170e7170058604765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Fri, 11 Mar 2022 16:57:31 +0100 Subject: [PATCH] [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 --- app/actions/remote/channel.ts | 180 +++++++- app/actions/remote/user.ts | 114 ++++- app/actions/websocket/users.ts | 11 +- app/components/channel_list/header/header.tsx | 4 +- .../channel_list/header/plus_menu/index.tsx | 8 +- app/components/profile_picture/image.tsx | 5 +- app/components/profile_picture/index.tsx | 5 +- app/components/profile_picture/status.tsx | 6 +- app/components/user_list_row/index.tsx | 218 ++++++++++ app/constants/general.ts | 5 + app/constants/screens.ts | 2 + .../create_direct_message.tsx | 407 ++++++++++++++++++ app/screens/create_direct_message/index.ts | 52 +++ .../selected_users/index.tsx | 138 ++++++ .../selected_users/selected_user.tsx | 105 +++++ .../create_direct_message/user_list.tsx | 275 ++++++++++++ app/screens/index.tsx | 3 + app/utils/user/index.ts | 88 +++- assets/base/i18n/de.json | 2 +- assets/base/i18n/en.json | 16 +- assets/base/i18n/en_AU.json | 2 +- assets/base/i18n/es.json | 2 +- assets/base/i18n/fr.json | 2 +- assets/base/i18n/it.json | 2 +- assets/base/i18n/ja.json | 2 +- assets/base/i18n/ko.json | 2 +- assets/base/i18n/nl.json | 2 +- assets/base/i18n/pl.json | 2 +- assets/base/i18n/pt-BR.json | 2 +- assets/base/i18n/ro.json | 2 +- assets/base/i18n/ru.json | 2 +- assets/base/i18n/tr.json | 2 +- assets/base/i18n/uk.json | 2 +- assets/base/i18n/zh-CN.json | 2 +- assets/base/i18n/zh-TW.json | 2 +- types/api/users.d.ts | 1 + 36 files changed, 1634 insertions(+), 41 deletions(-) create mode 100644 app/components/user_list_row/index.tsx create mode 100644 app/screens/create_direct_message/create_direct_message.tsx create mode 100644 app/screens/create_direct_message/index.ts create mode 100644 app/screens/create_direct_message/selected_users/index.tsx create mode 100644 app/screens/create_direct_message/selected_users/selected_user.tsx create mode 100644 app/screens/create_direct_message/user_list.tsx diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index accc15c8e..e7f8e34b4 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -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 { diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index 3e30235c9..1b413d320 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -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) { diff --git a/app/actions/websocket/users.ts b/app/actions/websocket/users.ts index 0d6da82bc..eca665cc3 100644 --- a/app/actions/websocket/users.ts +++ b/app/actions/websocket/users.ts @@ -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); diff --git a/app/components/channel_list/header/header.tsx b/app/components/channel_list/header/header.tsx index 3f8cacba9..98f899e8f 100644 --- a/app/components/channel_list/header/header.tsx +++ b/app/components/channel_list/header/header.tsx @@ -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'}), }); diff --git a/app/components/channel_list/header/plus_menu/index.tsx b/app/components/channel_list/header/plus_menu/index.tsx index 4832d30a7..846ff39fb 100644 --- a/app/components/channel_list/header/plus_menu/index.tsx +++ b/app/components/channel_list/header/plus_menu/index.tsx @@ -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 ( diff --git a/app/components/profile_picture/image.tsx b/app/components/profile_picture/image.tsx index 50a77fcba..cc9362bbe 100644 --- a/app/components/profile_picture/image.tsx +++ b/app/components/profile_picture/image.tsx @@ -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 ( { - if (author && !author.status && showStatus) { + if (!isBot && author && !author.status && showStatus) { fetchStatusInBatch(serverUrl, author.id); } }, []); diff --git a/app/components/profile_picture/status.tsx b/app/components/profile_picture/status.tsx index c416c972b..82fef49db 100644 --- a/app/components/profile_picture/status.tsx +++ b/app/components/profile_picture/status.tsx @@ -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; 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 ( 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 ( + + + {selected && + + } + + + ); + }; + + 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 ( + + + + + + + + + + {usernameDisplay} + + + + + + {showTeammateDisplay && + + + {teammateDisplay} + + + } + {user.delete_at > 0 && + + + {formatMessage({id: 'mobile.user_list.deactivated', defaultMessage: 'Deactivated'})} + + + } + + {selectable && + + } + + + ); +} + diff --git a/app/constants/general.ts b/app/constants/general.ts index c1d43b573..03b85be54 100644 --- a/app/constants/general.ts +++ b/app/constants/general.ts @@ -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', }; diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 419e27d85..38cddd460 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -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, diff --git a/app/screens/create_direct_message/create_direct_message.tsx b/app/screens/create_direct_message/create_direct_message.tsx new file mode 100644 index 000000000..268631a89 --- /dev/null +++ b/app/screens/create_direct_message/create_direct_message.tsx @@ -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(null); + const next = useRef(true); + const page = useRef(-1); + const mounted = useRef(false); + + const [profiles, setProfiles] = useState([]); + const [searchResults, setSearchResults] = useState([]); + 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 => { + 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 => { + 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 ( + + + + ); + } + + return ( + + + + + {selectedCount > 0 && + + } + + + ); +} + diff --git a/app/screens/create_direct_message/index.ts b/app/screens/create_direct_message/index.ts new file mode 100644 index 000000000..b725183f6 --- /dev/null +++ b/app/screens/create_direct_message/index.ts @@ -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(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG); + const license = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE); + const preferences = database.get(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe(); + const currentUserId = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap(({value}) => of$(value)), + ); + const currentTeamId = database.get(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)); + diff --git a/app/screens/create_direct_message/selected_users/index.tsx b/app/screens/create_direct_message/selected_users/index.tsx new file mode 100644 index 000000000..efc028b3f --- /dev/null +++ b/app/screens/create_direct_message/selected_users/index.tsx @@ -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( + , + ); + } + return u; + }, [selectedIds, teammateNameDisplay, onRemove]); + + const showWarn = users.length >= warnCount && users.length < maxCount; + + const message = useMemo(() => { + if (users.length >= maxCount) { + return ( + + ); + } else if (users.length >= warnCount) { + const remaining = maxCount - users.length; + if (remaining === 1) { + return ( + + ); + } + return ( + + ); + } + + return null; + }, [users.length >= maxCount, showWarn && users.length, theme, maxCount]); + + return ( + + + {users} + + {message} + + ); +} + diff --git a/app/screens/create_direct_message/selected_users/selected_user.tsx b/app/screens/create_direct_message/selected_users/selected_user.tsx new file mode 100644 index 000000000..03b1c7459 --- /dev/null +++ b/app/screens/create_direct_message/selected_users/selected_user.tsx @@ -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 ( + + + {displayUsername(user, intl.locale, teammateNameDisplay)} + + + + + + ); +} diff --git a/app/screens/create_direct_message/user_list.tsx b/app/screens/create_direct_message/user_list.tsx new file mode 100644 index 000000000..f8192912c --- /dev/null +++ b/app/screens/create_direct_message/user_list.tsx @@ -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) => { + // 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 ( + + ); + }, [selectedIds, currentUserId, handleSelectProfile, teammateNameDisplay]); + + const renderLoading = useCallback(() => { + if (!loading) { + return null; + } + + return ( + + + + ); + }, [loading && style]); + + const renderNoResults = useCallback(() => { + if (!showNoResults) { + return null; + } + + return ( + + + + ); + }, [showNoResults && style]); + + const renderSectionHeader = useCallback(({section}: {section: SectionListData}) => { + return ( + + + {section.id} + + + ); + }, [style]); + + const renderFlatList = (data: UserProfile[]) => { + return ( + + ); + }; + + const renderSectionList = (data: Array>) => { + return ( + + ); + }; + + const data = useMemo(() => { + if (isSearch) { + return profiles; + } + return createProfilesSections(profiles); + }, [isSearch, profiles]); + + if (isSearch) { + return renderFlatList(data as UserProfile[]); + } + return renderSectionList(data as Array>); +} + diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 9f80ed116..2cd64568b 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -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; diff --git a/app/utils/user/index.ts b/app/utils/user/index.ts index c66524b48..6b7260e0d 100644 --- a/app/utils/user/index.ts +++ b/app/utils/user/index.ts @@ -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()); + + 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)); + }); +} diff --git a/assets/base/i18n/de.json b/assets/base/i18n/de.json index f50342347..535e79e2b 100644 --- a/assets/base/i18n/de.json +++ b/assets/base/i18n/de.json @@ -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", diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 814dbf5f6..3279e0051 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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", diff --git a/assets/base/i18n/en_AU.json b/assets/base/i18n/en_AU.json index bcbc64f63..7d8788d82 100644 --- a/assets/base/i18n/en_AU.json +++ b/assets/base/i18n/en_AU.json @@ -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", diff --git a/assets/base/i18n/es.json b/assets/base/i18n/es.json index 457b36491..dc2aaf94e 100644 --- a/assets/base/i18n/es.json +++ b/assets/base/i18n/es.json @@ -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", diff --git a/assets/base/i18n/fr.json b/assets/base/i18n/fr.json index 28c94c94c..d44ec85bc 100644 --- a/assets/base/i18n/fr.json +++ b/assets/base/i18n/fr.json @@ -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", diff --git a/assets/base/i18n/it.json b/assets/base/i18n/it.json index 017f0a912..3fee530c7 100644 --- a/assets/base/i18n/it.json +++ b/assets/base/i18n/it.json @@ -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", diff --git a/assets/base/i18n/ja.json b/assets/base/i18n/ja.json index 74389859b..b909dbeb9 100644 --- a/assets/base/i18n/ja.json +++ b/assets/base/i18n/ja.json @@ -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": "すべて", diff --git a/assets/base/i18n/ko.json b/assets/base/i18n/ko.json index 0ad1df9fe..1a28cfd3d 100644 --- a/assets/base/i18n/ko.json +++ b/assets/base/i18n/ko.json @@ -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": "모두", diff --git a/assets/base/i18n/nl.json b/assets/base/i18n/nl.json index 5d64a216b..746343974 100644 --- a/assets/base/i18n/nl.json +++ b/assets/base/i18n/nl.json @@ -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", diff --git a/assets/base/i18n/pl.json b/assets/base/i18n/pl.json index 035d45531..691ba796f 100644 --- a/assets/base/i18n/pl.json +++ b/assets/base/i18n/pl.json @@ -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", diff --git a/assets/base/i18n/pt-BR.json b/assets/base/i18n/pt-BR.json index 68f709e8a..ebf8e385c 100644 --- a/assets/base/i18n/pt-BR.json +++ b/assets/base/i18n/pt-BR.json @@ -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", diff --git a/assets/base/i18n/ro.json b/assets/base/i18n/ro.json index 5888984e6..664ca64c6 100644 --- a/assets/base/i18n/ro.json +++ b/assets/base/i18n/ro.json @@ -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", diff --git a/assets/base/i18n/ru.json b/assets/base/i18n/ru.json index b95be5cae..2d3abba67 100644 --- a/assets/base/i18n/ru.json +++ b/assets/base/i18n/ru.json @@ -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": "Все", diff --git a/assets/base/i18n/tr.json b/assets/base/i18n/tr.json index 8c41efd14..6d0494630 100644 --- a/assets/base/i18n/tr.json +++ b/assets/base/i18n/tr.json @@ -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ü", diff --git a/assets/base/i18n/uk.json b/assets/base/i18n/uk.json index 50dc4664e..bbf5b1fa5 100644 --- a/assets/base/i18n/uk.json +++ b/assets/base/i18n/uk.json @@ -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", diff --git a/assets/base/i18n/zh-CN.json b/assets/base/i18n/zh-CN.json index bf9b0f4d2..b9ad97bf2 100644 --- a/assets/base/i18n/zh-CN.json +++ b/assets/base/i18n/zh-CN.json @@ -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": "全部", diff --git a/assets/base/i18n/zh-TW.json b/assets/base/i18n/zh-TW.json index 0dacf2b32..9695a7d5e 100644 --- a/assets/base/i18n/zh-TW.json +++ b/assets/base/i18n/zh-TW.json @@ -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": "全部", diff --git a/types/api/users.d.ts b/types/api/users.d.ts index acc28ae66..09be2f807 100644 --- a/types/api/users.d.ts +++ b/types/api/users.d.ts @@ -41,6 +41,7 @@ type UserProfile = { timezone?: UserTimezone; is_bot: boolean; last_picture_update: number; + remote_id?: string; status?: string; };