Add User Mention to autocomplete (#6005)

* Add User Mention to autocomplete

* Minor fixes

* Fix at_mention (you) alignment

* Add missing translation strings

* Make it more parallel to channel mentions

* Fix bot tag

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Daniel Espino García
2022-03-14 21:05:52 +01:00
committed by GitHub
parent 5178091ab0
commit 9f9190f5db
13 changed files with 862 additions and 11 deletions

View File

@@ -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);
};

View File

@@ -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 {

View File

@@ -33,7 +33,7 @@ export interface ClientUsersMix {
getUserByEmail: (email: string) => Promise<UserProfile>;
getProfilePictureUrl: (userId: string, lastPictureUpdate: number) => string;
getDefaultProfilePictureUrl: (userId: string) => string;
autocompleteUsers: (name: string, teamId: string, channelId: string, options?: Record<string, any>) => Promise<{users: UserProfile[]; out_of_channel?: UserProfile[]}>;
autocompleteUsers: (name: string, teamId: string, channelId?: string, options?: Record<string, any>) => Promise<{users: UserProfile[]; out_of_channel?: UserProfile[]}>;
getSessions: (userId: string) => Promise<Session[]>;
checkUserMfa: (loginId: string) => Promise<{mfa_required: boolean}>;
attachDevice: (deviceId: string) => Promise<any>;
@@ -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({

View File

@@ -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<SectionListData<UserProfile|Group|SpecialMention>>
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<UserMentionSections>([]);
const [usersInChannel, setUsersInChannel] = useState<UserProfile[]>([]);
const [usersOutOfChannel, setUsersOutOfChannel] = useState<UserProfile[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(false);
const [noResultsTerm, setNoResultsTerm] = useState<string|null>(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 (
<SpecialMentionItem
completeHandle={item.completeHandle}
defaultMessage={item.defaultMessage}
id={item.id}
onPress={completeMention}
/>
);
}, [completeMention]);
const renderGroupMentions = useCallback((item: Group) => {
return (
<GroupMentionItem
key={`autocomplete-group-${item.name}`}
completeHandle={item.name}
onPress={completeMention}
/>
);
}, [completeMention]);
const renderAtMentions = useCallback((item: UserProfile) => {
return (
<AtMentionItem
testID={`autocomplete.at_mention.item.${item}`}
onPress={completeMention}
user={item}
/>
);
}, [completeMention]);
const renderItem = useCallback(({item, section}: SectionListRenderItemInfo<SpecialMention | Group | UserProfile>) => {
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 (
<AutocompleteSectionHeader
id={section.id}
defaultMessage={section.defaultMessage}
loading={!section.hideLoadingIndicator && loading}
/>
);
}, [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 (
<SectionList
keyboardShouldPersistTaps='always'
keyExtractor={keyExtractor}
initialNumToRender={10}
nestedScrollEnabled={nestedScrollEnabled}
removeClippedSubviews={Platform.OS === 'android'}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
style={[style.listView, {maxHeight: maxListHeight}]}
sections={sections}
testID='at_mention_suggestion.list'
/>
);
};
export default AtMention;

View File

@@ -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<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap(({value}) => of$(value)),
).pipe(
switchMap((id) => database.get<UserModel>(USER).findAndObserve(id)),
);
const hasLicense = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(
switchMap(({value}) => of$(value?.IsLicensed === 'true')),
);
let useChannelMentions: Observable<boolean>;
let useGroupMentions: Observable<boolean>;
if (channelId) {
const currentChannel = database.get<ChannelModel>(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));

View File

@@ -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 (
<TouchableWithFeedback
onPress={completeMention}
style={touchableStyle}
type={'opacity'}
>
<View style={style.rowPicture}>
<CompassIcon
name='account-multiple-outline'
style={style.rowIcon}
/>
</View>
<Text style={style.rowUsername}>{`@${completeHandle} - `}</Text>
<Text style={style.rowFullname}>{`${completeHandle}`}</Text>
</TouchableWithFeedback>
);
};
export default GroupMentionItem;

View File

@@ -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 (
<TouchableWithFeedback
testID={testID}
key={user.id}
onPress={completeMention}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
style={{marginLeft: insets.left, marginRight: insets.right}}
type={'native'}
>
<View style={style.row}>
<View style={style.rowPicture}>
<ProfilePicture
author={user}
size={24}
showStatus={false}
testID='at_mention_item.profile_picture'
/>
</View>
<View
style={[style.rowInfo, {maxWidth: shared ? '75%' : '80%'}]}
>
{Boolean(user.is_bot) && (<BotTag/>)}
{guest && (<GuestTag/>)}
{Boolean(name.length) && (
<Text
style={style.rowFullname}
numberOfLines={1}
testID='at_mention_item.name'
>
{name}
</Text>
)}
{isCurrentUser && (
<FormattedText
id='suggestion.mention.you'
defaultMessage=' (you)'
style={style.rowUsername}
/>
)}
<Text
style={style.rowUsername}
numberOfLines={1}
testID='at_mention_item.username'
>
{` @${user.username}`}
</Text>
</View>
{isCustomStatusEnabled && !user.is_bot && customStatus && (
<CustomStatusEmoji
customStatus={customStatus}
style={style.icon}
/>
)}
{shared && (
<ChannelIcon
name={name}
isActive={false}
isArchived={false}
isInfo={true}
isUnread={true}
size={18}
shared={true}
type={General.DM_CHANNEL}
style={style.icon}
/>
)}
</View>
</TouchableWithFeedback>
);
};
export default AtMentionItem;

View File

@@ -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<SystemModel>(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<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap(({value}) => of$(value)),
);
return {
isCustomStatusEnabled,
showFullName,
currentUserId,
};
});
export default withDatabase(enhanced(AtMentionItem));

View File

@@ -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) && (<>
{/* <AtMention
<AtMention
cursorPosition={cursorPosition}
maxListHeight={maxListHeight}
updateValue={updateValue}
onResultCountChange={setShowingAtMention}
onShowingChange={setShowingAtMention}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
/> */}
isSearch={isSearch}
channelId={channelId}
/>
<ChannelMention
cursorPosition={cursorPosition}
maxListHeight={maxListHeight}

View File

@@ -0,0 +1,99 @@
// 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 CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
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 (
<TouchableWithFeedback
onPress={completeMention}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
type={'native'}
>
<View style={style.row}>
<View style={style.rowPicture}>
<CompassIcon
name='account-multiple-outline'
style={style.rowIcon}
/>
</View>
<Text
style={style.textWrapper}
numberOfLines={1}
>
<Text style={style.rowUsername}>{`@${completeHandle} - `}</Text>
<FormattedText
id={id}
defaultMessage={defaultMessage}
style={style.rowFullname}
/>
</Text>
</View>
</TouchableWithFeedback>
);
};
export default SpecialMentionItem;

View File

@@ -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;

View File

@@ -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",

View File

@@ -43,6 +43,7 @@ type UserProfile = {
last_picture_update: number;
remote_id?: string;
status?: string;
remote_id?: string;
};
type UsersState = {