diff --git a/app/actions/remote/groups.ts b/app/actions/remote/groups.ts new file mode 100644 index 0000000000..6705933d81 --- /dev/null +++ b/app/actions/remote/groups.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Client} from '@client/rest'; +import NetworkManager from '@init/network_manager'; + +export const getGroupsForAutocomplete = async (serverUrl: string, channelId: string) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return []; + } + + return client.getAllGroupsAssociatedToChannel(channelId, true); +}; diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index 1b413d3207..2aaaf7ef93 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -13,7 +13,7 @@ import {MM_TABLES} from '@constants/database'; import DatabaseManager from '@database/manager'; import {debounce} from '@helpers/api/general'; import NetworkManager from '@init/network_manager'; -import {queryCurrentUserId} from '@queries/servers/system'; +import {queryCurrentTeamId, queryCurrentUserId} from '@queries/servers/system'; import {prepareUsers, queryAllUsers, queryCurrentUser, queryUsersById, queryUsersByUsername} from '@queries/servers/user'; import {forceLogoutIfNecessary} from './session'; @@ -666,6 +666,28 @@ export const uploadUserProfileImage = async (serverUrl: string, localPath: strin return {error: undefined}; }; +export const searchUsers = async (serverUrl: string, term: string, channelId?: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const currentTeamId = await queryCurrentTeamId(database); + const users = await client.autocompleteUsers(term, currentTeamId, channelId); + return {users}; + } catch (error) { + return {error}; + } +}; + export const buildProfileImageUrl = (serverUrl: string, userId: string, timestamp = 0) => { let client: Client; try { diff --git a/app/client/rest/users.ts b/app/client/rest/users.ts index 525740cc12..07f33f3b30 100644 --- a/app/client/rest/users.ts +++ b/app/client/rest/users.ts @@ -33,7 +33,7 @@ export interface ClientUsersMix { getUserByEmail: (email: string) => Promise; getProfilePictureUrl: (userId: string, lastPictureUpdate: number) => string; getDefaultProfilePictureUrl: (userId: string) => string; - autocompleteUsers: (name: string, teamId: string, channelId: string, options?: Record) => Promise<{users: UserProfile[]; out_of_channel?: UserProfile[]}>; + autocompleteUsers: (name: string, teamId: string, channelId?: string, options?: Record) => Promise<{users: UserProfile[]; out_of_channel?: UserProfile[]}>; getSessions: (userId: string) => Promise; checkUserMfa: (loginId: string) => Promise<{mfa_required: boolean}>; attachDevice: (deviceId: string) => Promise; @@ -320,7 +320,7 @@ const ClientUsers = (superclass: any) => class extends superclass { return `${this.getUserRoute(userId)}/image/default`; }; - autocompleteUsers = async (name: string, teamId: string, channelId: string, options = { + autocompleteUsers = async (name: string, teamId: string, channelId?: string, options = { limit: General.AUTOCOMPLETE_LIMIT_DEFAULT, }) => { return this.doFetch(`${this.getUsersRoute()}/autocomplete${buildQueryString({ diff --git a/app/components/autocomplete/at_mention/at_mention.tsx b/app/components/autocomplete/at_mention/at_mention.tsx new file mode 100644 index 0000000000..825b52ca46 --- /dev/null +++ b/app/components/autocomplete/at_mention/at_mention.tsx @@ -0,0 +1,347 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {debounce} from 'lodash'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {Platform, SectionList, SectionListData, SectionListRenderItemInfo} from 'react-native'; + +import {getGroupsForAutocomplete} from '@actions/remote/groups'; +import {searchUsers} from '@actions/remote/user'; +import GroupMentionItem from '@components/autocomplete/at_mention_group/at_mention_group'; +import AtMentionItem from '@components/autocomplete/at_mention_item'; +import AutocompleteSectionHeader from '@components/autocomplete/autocomplete_section_header'; +import SpecialMentionItem from '@components/autocomplete/special_mention_item'; +import {AT_MENTION_REGEX, AT_MENTION_SEARCH_REGEX} from '@constants/autocomplete'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {t} from '@i18n'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +const SECTION_KEY_TEAM_MEMBERS = 'teamMembers'; +const SECTION_KEY_IN_CHANNEL = 'inChannel'; +const SECTION_KEY_OUT_OF_CHANNEL = 'outChannel'; +const SECTION_KEY_SPECIAL = 'special'; +const SECTION_KEY_GROUPS = 'groups'; + +type SpecialMention = { + completeHandle: string; + id: string; + defaultMessage: string; +} + +type UserMentionSections = Array> + +const getMatchTermForAtMention = (() => { + let lastMatchTerm: string | null = null; + let lastValue: string; + let lastIsSearch: boolean; + return (value: string, isSearch: boolean) => { + if (value !== lastValue || isSearch !== lastIsSearch) { + const regex = isSearch ? AT_MENTION_SEARCH_REGEX : AT_MENTION_REGEX; + let term = value; + if (term.startsWith('from: @') || term.startsWith('from:@')) { + term = term.replace('@', ''); + } + + const match = term.match(regex); + lastValue = value; + lastIsSearch = isSearch; + if (match) { + lastMatchTerm = (isSearch ? match[1] : match[2]).toLowerCase(); + } else { + lastMatchTerm = null; + } + } + return lastMatchTerm; + }; +})(); + +const getSpecialMentions: () => SpecialMention[] = () => { + return [{ + completeHandle: 'all', + id: t('suggestion.mention.all'), + defaultMessage: 'Notifies everyone in this channel', + }, { + completeHandle: 'channel', + id: t('suggestion.mention.channel'), + defaultMessage: 'Notifies everyone in this channel', + }, { + completeHandle: 'here', + id: t('suggestion.mention.here'), + defaultMessage: 'Notifies everyone online in this channel', + }]; +}; + +const checkSpecialMentions = (term: string) => { + return getSpecialMentions().filter((m) => m.completeHandle.startsWith(term)).length > 0; +}; + +const keyExtractor = (item: UserProfile) => { + return item.id; +}; + +const makeSections = (teamMembers: UserProfile[], usersInChannel: UserProfile[], usersOutOfChannel: UserProfile[], groups: Group[], showSpecialMentions: boolean, isSearch = false) => { + const newSections: UserMentionSections = []; + + if (isSearch) { + newSections.push({ + id: t('mobile.suggestion.members'), + defaultMessage: 'Members', + data: teamMembers, + key: SECTION_KEY_TEAM_MEMBERS, + }); + } else { + if (usersInChannel.length) { + newSections.push({ + id: t('suggestion.mention.members'), + defaultMessage: 'Channel Members', + data: usersInChannel, + key: SECTION_KEY_IN_CHANNEL, + }); + } + + if (groups.length) { + newSections.push({ + id: t('suggestion.mention.groups'), + defaultMessage: 'Group Mentions', + data: groups, + key: SECTION_KEY_GROUPS, + }); + } + + if (showSpecialMentions) { + newSections.push({ + id: t('suggestion.mention.special'), + defaultMessage: 'Special Mentions', + data: getSpecialMentions(), + key: SECTION_KEY_SPECIAL, + }); + } + + if (usersOutOfChannel.length) { + newSections.push({ + id: t('suggestion.mention.nonmembers'), + defaultMessage: 'Not in Channel', + data: usersOutOfChannel, + key: SECTION_KEY_OUT_OF_CHANNEL, + }); + } + } + return newSections; +}; + +type Props = { + channelId?: string; + cursorPosition: number; + isSearch: boolean; + maxListHeight: number; + updateValue: (v: string) => void; + onShowingChange: (c: boolean) => void; + value: string; + nestedScrollEnabled: boolean; + useChannelMentions: boolean; + useGroupMentions: boolean; +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + listView: { + backgroundColor: theme.centerChannelBg, + borderRadius: 4, + }, + }; +}); + +const emptyList: UserProfile[] = []; + +const AtMention = ({ + channelId, + cursorPosition, + isSearch, + maxListHeight, + updateValue, + onShowingChange, + value, + nestedScrollEnabled, + useChannelMentions, + useGroupMentions, +}: Props) => { + const serverUrl = useServerUrl(); + const theme = useTheme(); + const style = getStyleFromTheme(theme); + + const [sections, setSections] = useState([]); + const [usersInChannel, setUsersInChannel] = useState([]); + const [usersOutOfChannel, setUsersOutOfChannel] = useState([]); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [noResultsTerm, setNoResultsTerm] = useState(null); + const [localCursorPosition, setLocalCursorPosition] = useState(cursorPosition); // To avoid errors due to delay between value changes and cursor position changes. + + const runSearch = useMemo(() => debounce(async (sUrl: string, term: string, cId?: string) => { + setLoading(true); + const {users: receivedUsers, error} = await searchUsers(sUrl, term, cId); + if (!error) { + setUsersInChannel(receivedUsers!.users); + setUsersOutOfChannel(receivedUsers!.out_of_channel || emptyList); + } + setLoading(false); + }, 200), []); + + const teamMembers = useMemo( + () => [...usersInChannel, ...usersOutOfChannel], + [usersInChannel, usersOutOfChannel], + ); + + const matchTerm = getMatchTermForAtMention(value.substring(0, localCursorPosition), isSearch); + const resetState = () => { + setUsersInChannel(emptyList); + setUsersOutOfChannel(emptyList); + setSections([]); + runSearch.cancel(); + }; + + const completeMention = useCallback((mention) => { + const mentionPart = value.substring(0, localCursorPosition); + + let completedDraft; + if (isSearch) { + completedDraft = mentionPart.replace(AT_MENTION_SEARCH_REGEX, `from: ${mention} `); + } else { + completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `); + } + + const newCursorPosition = completedDraft.length - 1; + + if (value.length > cursorPosition) { + completedDraft += value.substring(cursorPosition); + } + + updateValue(completedDraft); + setLocalCursorPosition(newCursorPosition); + + onShowingChange(false); + setNoResultsTerm(mention); + setSections([]); + }, [value, localCursorPosition, isSearch]); + + const renderSpecialMentions = useCallback((item: SpecialMention) => { + return ( + + ); + }, [completeMention]); + + const renderGroupMentions = useCallback((item: Group) => { + return ( + + ); + }, [completeMention]); + + const renderAtMentions = useCallback((item: UserProfile) => { + return ( + + ); + }, [completeMention]); + + const renderItem = useCallback(({item, section}: SectionListRenderItemInfo) => { + switch (section.key) { + case SECTION_KEY_SPECIAL: + return renderSpecialMentions(item as SpecialMention); + case SECTION_KEY_GROUPS: + return renderGroupMentions(item as Group); + default: + return renderAtMentions(item as UserProfile); + } + }, [renderSpecialMentions, renderGroupMentions, renderAtMentions]); + + const renderSectionHeader = useCallback(({section}) => { + return ( + + ); + }, [loading]); + + useEffect(() => { + if (localCursorPosition !== cursorPosition) { + setLocalCursorPosition(cursorPosition); + } + }, [cursorPosition]); + + useEffect(() => { + if (useGroupMentions) { + getGroupsForAutocomplete(serverUrl, channelId || '').then((res) => { + setGroups(res); + }).catch(() => { + setGroups([]); + }); + } else { + setGroups([]); + } + }, [channelId, useGroupMentions]); + + useEffect(() => { + if (matchTerm === null) { + resetState(); + onShowingChange(false); + return; + } + + if (noResultsTerm != null && matchTerm.startsWith(noResultsTerm)) { + return; + } + + setNoResultsTerm(null); + runSearch(serverUrl, matchTerm, channelId); + }, [matchTerm]); + + useEffect(() => { + const showSpecialMentions = useChannelMentions && matchTerm != null && checkSpecialMentions(matchTerm); + const newSections = makeSections(teamMembers, usersInChannel, usersOutOfChannel, groups, showSpecialMentions, isSearch); + const nSections = newSections.length; + + if (!loading && !nSections && noResultsTerm == null) { + setNoResultsTerm(matchTerm); + } + setSections(newSections); + onShowingChange(Boolean(nSections)); + }, [usersInChannel, usersOutOfChannel, teamMembers, groups, loading]); + + if (sections.length === 0 || noResultsTerm != null) { + // If we are not in an active state or the mention has been completed return null so nothing is rendered + // other components are not blocked. + return null; + } + + return ( + + ); +}; + +export default AtMention; diff --git a/app/components/autocomplete/at_mention/index.ts b/app/components/autocomplete/at_mention/index.ts new file mode 100644 index 0000000000..f56b25bf1d --- /dev/null +++ b/app/components/autocomplete/at_mention/index.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$, from as from$, combineLatest, Observable} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {Permissions} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {hasPermissionForChannel} from '@utils/role'; + +import AtMention from './at_mention'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; + +const {SERVER: {SYSTEM, USER, CHANNEL}} = MM_TABLES; + +type OwnProps = {channelId?: string} +const enhanced = withObservables([], ({database, channelId}: WithDatabaseArgs & OwnProps) => { + const currentUser = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap(({value}) => of$(value)), + ).pipe( + switchMap((id) => database.get(USER).findAndObserve(id)), + ); + + const hasLicense = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe( + switchMap(({value}) => of$(value?.IsLicensed === 'true')), + ); + + let useChannelMentions: Observable; + let useGroupMentions: Observable; + if (channelId) { + const currentChannel = database.get(CHANNEL).findAndObserve(channelId); + useChannelMentions = combineLatest([currentUser, currentChannel]).pipe(switchMap(([u, c]) => from$(hasPermissionForChannel(c, u, Permissions.USE_CHANNEL_MENTIONS, false)))); + useGroupMentions = combineLatest([currentUser, currentChannel, hasLicense]).pipe( + switchMap(([u, c, lcs]) => (lcs ? from$(hasPermissionForChannel(c, u, Permissions.USE_GROUP_MENTIONS, false)) : of$(false))), + ); + } else { + useChannelMentions = of$(false); + useGroupMentions = of$(false); + } + + return { + useChannelMentions, + useGroupMentions, + }; +}); + +export default withDatabase(enhanced(AtMention)); diff --git a/app/components/autocomplete/at_mention_group/at_mention_group.tsx b/app/components/autocomplete/at_mention_group/at_mention_group.tsx new file mode 100644 index 0000000000..ff3a9b9f07 --- /dev/null +++ b/app/components/autocomplete/at_mention_group/at_mention_group.tsx @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import { + Text, + View, +} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + row: { + paddingVertical: 8, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + }, + rowPicture: { + marginHorizontal: 8, + width: 20, + alignItems: 'center', + justifyContent: 'center', + }, + rowIcon: { + color: changeOpacity(theme.centerChannelColor, 0.7), + fontSize: 14, + }, + rowUsername: { + fontSize: 13, + color: theme.centerChannelColor, + }, + rowFullname: { + color: theme.centerChannelColor, + flex: 1, + opacity: 0.6, + }, + textWrapper: { + flex: 1, + flexWrap: 'wrap', + paddingRight: 8, + }, + }; +}); + +type Props = { + completeHandle: string; + onPress: (handle: string) => void; +} + +const GroupMentionItem = ({ + onPress, + completeHandle, +}: Props) => { + const insets = useSafeAreaInsets(); + const theme = useTheme(); + const style = getStyleFromTheme(theme); + + const touchableStyle = useMemo( + () => [style.row, {marginLeft: insets.left, marginRight: insets.right}], + [insets.left, insets.right, style], + ); + const completeMention = useCallback(() => { + onPress(completeHandle); + }, [onPress, completeHandle]); + + return ( + + + + + {`@${completeHandle} - `} + {`${completeHandle}`} + + ); +}; + +export default GroupMentionItem; diff --git a/app/components/autocomplete/at_mention_item/at_mention_item.tsx b/app/components/autocomplete/at_mention_item/at_mention_item.tsx new file mode 100644 index 0000000000..b1227e778d --- /dev/null +++ b/app/components/autocomplete/at_mention_item/at_mention_item.tsx @@ -0,0 +1,175 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {Text, View} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import ChannelIcon from '@components/channel_icon'; +import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; +import FormattedText from '@components/formatted_text'; +import ProfilePicture from '@components/profile_picture'; +import {BotTag, GuestTag} from '@components/tag'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {General} from '@constants'; +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; +import {getUserCustomStatus, isGuest, isShared} from '@utils/user'; + +type AtMentionItemProps = { + user: UserProfile; + currentUserId: string; + onPress: (username: string) => void; + showFullName: boolean; + testID?: string; + isCustomStatusEnabled: boolean; +} + +const getName = (user: UserProfile, showFullName: boolean, isCurrentUser: boolean) => { + let name = ''; + const hasNickname = user.nickname.length > 0; + + if (showFullName) { + name += `${user.first_name} ${user.last_name} `; + } + + if (hasNickname && !isCurrentUser) { + name += name.length > 0 ? `(${user.nickname})` : user.nickname; + } + + return name.trim(); +}; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + row: { + height: 40, + paddingVertical: 8, + paddingTop: 4, + paddingHorizontal: 16, + flexDirection: 'row', + alignItems: 'center', + }, + rowPicture: { + marginRight: 10, + marginLeft: 2, + width: 24, + alignItems: 'center', + justifyContent: 'center', + }, + rowInfo: { + flexDirection: 'row', + overflow: 'hidden', + }, + rowFullname: { + fontSize: 15, + color: theme.centerChannelColor, + paddingLeft: 4, + flexShrink: 1, + }, + rowUsername: { + color: changeOpacity(theme.centerChannelColor, 0.56), + fontSize: 15, + flexShrink: 5, + }, + icon: { + marginLeft: 4, + }, + }; +}); + +const AtMentionItem = ({ + user, + currentUserId, + onPress, + showFullName, + testID, + isCustomStatusEnabled, +}: AtMentionItemProps) => { + const insets = useSafeAreaInsets(); + const theme = useTheme(); + const style = getStyleFromTheme(theme); + + const guest = isGuest(user.roles); + const shared = isShared(user); + + const completeMention = useCallback(() => { + onPress(user.username); + }, [user.username]); + + const isCurrentUser = currentUserId === user.id; + const name = getName(user, showFullName, isCurrentUser); + const customStatus = getUserCustomStatus(user); + + return ( + + + + + + + {Boolean(user.is_bot) && ()} + {guest && ()} + {Boolean(name.length) && ( + + {name} + + )} + {isCurrentUser && ( + + )} + + {` @${user.username}`} + + + {isCustomStatusEnabled && !user.is_bot && customStatus && ( + + )} + {shared && ( + + )} + + + ); +}; + +export default AtMentionItem; diff --git a/app/components/autocomplete/at_mention_item/index.ts b/app/components/autocomplete/at_mention_item/index.ts new file mode 100644 index 0000000000..eed265068a --- /dev/null +++ b/app/components/autocomplete/at_mention_item/index.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; + +import AtMentionItem from './at_mention_item'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type SystemModel from '@typings/database/models/servers/system'; + +const {SERVER: {SYSTEM}} = MM_TABLES; +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const config = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap(({value}) => of$(value as ClientConfig)), + ); + const isCustomStatusEnabled = config.pipe( + switchMap((cfg) => of$(cfg.EnableCustomUserStatuses === 'true')), + ); + const showFullName = config.pipe( + switchMap((cfg) => of$(cfg.ShowFullName === 'true')), + ); + const currentUserId = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap(({value}) => of$(value)), + ); + return { + isCustomStatusEnabled, + showFullName, + currentUserId, + }; +}); + +export default withDatabase(enhanced(AtMentionItem)); diff --git a/app/components/autocomplete/autocomplete.tsx b/app/components/autocomplete/autocomplete.tsx index 7b01c5f4b6..5b82aeb9b6 100644 --- a/app/components/autocomplete/autocomplete.tsx +++ b/app/components/autocomplete/autocomplete.tsx @@ -9,6 +9,7 @@ import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import AtMention from './at_mention/'; import ChannelMention from './channel_mention/'; import EmojiSuggestion from './emoji_suggestion/'; @@ -71,8 +72,7 @@ const Autocomplete = ({ cursorPosition, postInputTop, rootId, - - //channelId, + channelId, isSearch = false, fixedBottomPosition, value, @@ -89,7 +89,7 @@ const Autocomplete = ({ const dimensions = useWindowDimensions(); const style = getStyleFromTheme(theme); - // const [showingAtMention, setShowingAtMention] = useState(false); + const [showingAtMention, setShowingAtMention] = useState(false); const [showingChannelMention, setShowingChannelMention] = useState(false); const [showingEmoji, setShowingEmoji] = useState(false); @@ -97,7 +97,7 @@ const Autocomplete = ({ // const [showingAppCommand, setShowingAppCommand] = useState(false); // const [showingDate, setShowingDate] = useState(false); - const hasElements = showingChannelMention || showingEmoji; // || showingAtMention || showingCommand || showingAppCommand || showingDate; + const hasElements = showingChannelMention || showingEmoji || showingAtMention; // || showingCommand || showingAppCommand || showingDate; const appsTakeOver = false; // showingAppCommand; const maxListHeight = useMemo(() => { @@ -161,14 +161,16 @@ const Autocomplete = ({ /> )} */} {(!appsTakeOver || !isAppsEnabled) && (<> - {/* */} + isSearch={isSearch} + channelId={channelId} + /> { + return { + row: { + height: 40, + paddingVertical: 8, + paddingHorizontal: 9, + flexDirection: 'row', + alignItems: 'center', + }, + rowPicture: { + marginHorizontal: 8, + width: 20, + alignItems: 'center', + justifyContent: 'center', + }, + rowIcon: { + color: changeOpacity(theme.centerChannelColor, 0.7), + fontSize: 18, + }, + rowUsername: { + fontSize: 15, + color: theme.centerChannelColor, + }, + rowFullname: { + color: theme.centerChannelColor, + flex: 1, + opacity: 0.6, + }, + textWrapper: { + flex: 1, + flexWrap: 'wrap', + paddingRight: 8, + }, + }; +}); + +type Props = { + completeHandle: string; + defaultMessage: string; + id: string; + onPress: (handle: string) => void; +} +const SpecialMentionItem = ({ + completeHandle, + defaultMessage, + id, + onPress, +}: Props) => { + const theme = useTheme(); + const style = getStyleFromTheme(theme); + const completeMention = useCallback(() => { + onPress(completeHandle); + }, [completeHandle, onPress]); + + return ( + + + + + + + {`@${completeHandle} - `} + + + + + ); +}; + +export default SpecialMentionItem; diff --git a/app/utils/user/index.ts b/app/utils/user/index.ts index 6b7260e0df..147e627446 100644 --- a/app/utils/user/index.ts +++ b/app/utils/user/index.ts @@ -123,7 +123,7 @@ export const getTimezone = (timezone: UserTimezone | null) => { return timezone.manualTimezone; }; -export const getUserCustomStatus = (user: UserModel): UserCustomStatus | undefined => { +export const getUserCustomStatus = (user: UserModel | UserProfile): UserCustomStatus | undefined => { try { if (typeof user.props?.customStatus === 'string') { return JSON.parse(user.props.customStatus) as UserCustomStatus; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index e58136a508..9c0f13fd34 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -363,6 +363,7 @@ "mobile.set_status.online": "Online", "mobile.storage_permission_denied_description": "Upload files to your server. Open Settings to grant {applicationName} Read and Write access to files on this device.", "mobile.storage_permission_denied_title": "{applicationName} would like to access your files", + "mobile.suggestion.members": "Members", "mobile.system_message.channel_archived_message": "{username} archived the channel", "mobile.system_message.channel_unarchived_message": "{username} unarchived the channel", "mobile.system_message.update_channel_displayname_message_and_forget.updated_from": "{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}", @@ -449,8 +450,16 @@ "status_dropdown.set_offline": "Offline", "status_dropdown.set_online": "Online", "status_dropdown.set_ooo": "Out Of Office", + "suggestion.mention.all": "Notifies everyone in this channel", + "suggestion.mention.channel": "Notifies everyone in this channel", "suggestion.mention.channels": "My Channels", + "suggestion.mention.groups": "Group Mentions", + "suggestion.mention.here": "Notifies everyone online in this channel", + "suggestion.mention.members": "Channel Members", "suggestion.mention.morechannels": "Other Channels", + "suggestion.mention.nonmembers": "Not in Channel", + "suggestion.mention.special": "Special Mentions", + "suggestion.mention.you": " (you)", "suggestion.search.direct": "Direct Messages", "suggestion.search.private": "Private Channels", "suggestion.search.public": "Public Channels", diff --git a/types/api/users.d.ts b/types/api/users.d.ts index 09be2f8073..465972da2e 100644 --- a/types/api/users.d.ts +++ b/types/api/users.d.ts @@ -43,6 +43,7 @@ type UserProfile = { last_picture_update: number; remote_id?: string; status?: string; + remote_id?: string; }; type UsersState = {