forked from Ivasoft/mattermost-mobile
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:
committed by
GitHub
parent
5178091ab0
commit
9f9190f5db
16
app/actions/remote/groups.ts
Normal file
16
app/actions/remote/groups.ts
Normal 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);
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
347
app/components/autocomplete/at_mention/at_mention.tsx
Normal file
347
app/components/autocomplete/at_mention/at_mention.tsx
Normal 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;
|
||||
53
app/components/autocomplete/at_mention/index.ts
Normal file
53
app/components/autocomplete/at_mention/index.ts
Normal 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));
|
||||
@@ -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;
|
||||
175
app/components/autocomplete/at_mention_item/at_mention_item.tsx
Normal file
175
app/components/autocomplete/at_mention_item/at_mention_item.tsx
Normal 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;
|
||||
37
app/components/autocomplete/at_mention_item/index.ts
Normal file
37
app/components/autocomplete/at_mention_item/index.ts
Normal 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));
|
||||
@@ -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}
|
||||
|
||||
99
app/components/autocomplete/special_mention_item.tsx
Normal file
99
app/components/autocomplete/special_mention_item.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
types/api/users.d.ts
vendored
1
types/api/users.d.ts
vendored
@@ -43,6 +43,7 @@ type UserProfile = {
|
||||
last_picture_update: number;
|
||||
remote_id?: string;
|
||||
status?: string;
|
||||
remote_id?: string;
|
||||
};
|
||||
|
||||
type UsersState = {
|
||||
|
||||
Reference in New Issue
Block a user