Merge branch 'gekidou' into gekidou-fixes

This commit is contained in:
Elias Nahum 2022-03-24 16:19:39 -03:00
commit b6f373df5c
No known key found for this signature in database
GPG key ID: E038DB71E0B61702
64 changed files with 3659 additions and 341 deletions

View file

@ -4,6 +4,7 @@
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getRecentReactions} from '@queries/servers/system';
import {getEmojiFirstAlias} from '@utils/emoji/helpers';
const MAXIMUM_RECENT_EMOJI = 27;
@ -22,16 +23,17 @@ export const addRecentReaction = async (serverUrl: string, emojiNames: string[],
try {
const recentEmojis = new Set(recent);
for (const name of emojiNames) {
if (recentEmojis.has(name)) {
recentEmojis.delete(name);
const aliases = emojiNames.map((e) => getEmojiFirstAlias(e));
for (const alias of aliases) {
if (recentEmojis.has(alias)) {
recentEmojis.delete(alias);
}
}
recent = Array.from(recentEmojis);
for (const name of emojiNames) {
recent.unshift(name);
for (const alias of aliases) {
recent.unshift(alias);
}
return operator.handleSystem({
systems: [{

View file

@ -4,6 +4,7 @@
import {IntlShape} from 'react-intl';
import {sendEphemeralPost} from '@actions/local/post';
import ClientError from '@client/rest/error';
import CompassIcon from '@components/compass_icon';
import {Screens} from '@constants';
import {AppCallResponseTypes, AppCallTypes} from '@constants/apps';
@ -20,7 +21,7 @@ export async function doAppCall<Res=unknown>(serverUrl: string, call: AppCallReq
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
return {error: makeCallErrorResponse((error as ClientError).message)};
}
try {

View file

@ -914,3 +914,19 @@ export const searchChannels = async (serverUrl: string, term: string) => {
return {error};
}
};
export const fetchChannelById = async (serverUrl: string, id: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const channel = await client.getChannel(id);
return {channel};
} catch (error) {
return {error};
}
};

View file

@ -200,3 +200,32 @@ export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, loc
}
return {data: true};
};
export const fetchCommands = async (serverUrl: string, teamId: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error: error as ClientErrorProps};
}
try {
return {commands: await client.getCommandsList(teamId)};
} catch (error) {
return {error: error as ClientErrorProps};
}
};
export const fetchSuggestions = async (serverUrl: string, term: string, teamId: string, channelId: string, rootId?: string) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error: error as ClientErrorProps};
}
try {
return {suggestions: await client.getCommandAutocompleteSuggestionsList(term, teamId, channelId, rootId)};
} catch (error) {
return {error: error as ClientErrorProps};
}
};

View file

@ -7,8 +7,9 @@ import {addRecentReaction} from '@actions/local/reactions';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {getRecentPostsInChannel, getRecentPostsInThread} from '@queries/servers/post';
import {queryReaction} from '@queries/servers/reactions';
import {queryReaction} from '@queries/servers/reaction';
import {getCurrentChannelId, getCurrentUserId} from '@queries/servers/system';
import {getEmojiFirstAlias} from '@utils/emoji/helpers';
import {forceLogoutIfNecessary} from './session';
@ -30,29 +31,41 @@ export const addReaction = async (serverUrl: string, postId: string, emojiName:
try {
const currentUserId = await getCurrentUserId(operator.database);
const reaction = await client.addReaction(currentUserId, postId, emojiName);
const models: Model[] = [];
const emojiAlias = getEmojiFirstAlias(emojiName);
const reacted = await queryReaction(operator.database, emojiAlias, postId, currentUserId).fetchCount() > 0;
if (!reacted) {
const reaction = await client.addReaction(currentUserId, postId, emojiAlias);
const models: Model[] = [];
const reactions = await operator.handleReactions({
postsReactions: [{
const reactions = await operator.handleReactions({
postsReactions: [{
post_id: postId,
reactions: [reaction],
}],
prepareRecordsOnly: true,
skipSync: true, // this prevents the handler from deleting previous reactions
});
models.push(...reactions);
const recent = await addRecentReaction(serverUrl, [emojiName], true);
if (Array.isArray(recent)) {
models.push(...recent);
}
if (models.length) {
await operator.batchRecords(models);
}
return {reaction};
}
return {
reaction: {
user_id: currentUserId,
post_id: postId,
reactions: [reaction],
}],
prepareRecordsOnly: true,
skipSync: true, // this prevents the handler from deleting previous reactions
});
models.push(...reactions);
const recent = await addRecentReaction(serverUrl, [emojiName], true);
if (Array.isArray(recent)) {
models.push(...recent);
}
if (models.length) {
await operator.batchRecords(models);
}
return {reaction};
emoji_name: emojiAlias,
create_at: 0,
} as Reaction,
};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
@ -74,10 +87,11 @@ export const removeReaction = async (serverUrl: string, postId: string, emojiNam
try {
const currentUserId = await getCurrentUserId(database);
await client.removeReaction(currentUserId, postId, emojiName);
const emojiAlias = getEmojiFirstAlias(emojiName);
await client.removeReaction(currentUserId, postId, emojiAlias);
// should return one or no reaction
const reaction = await queryReaction(database, emojiName, postId, currentUserId).fetch();
const reaction = await queryReaction(database, emojiAlias, postId, currentUserId).fetch();
if (reaction.length) {
await database.write(async () => {

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {queryReaction} from '@queries/servers/reactions';
import {queryReaction} from '@queries/servers/reaction';
export async function handleAddCustomEmoji(serverUrl: string, msg: WebSocketMessage): Promise<void> {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;

View file

@ -7,7 +7,7 @@ import {PER_PAGE_DEFAULT} from './constants';
export interface ClientIntegrationsMix {
getCommandsList: (teamId: string) => Promise<Command[]>;
getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, commandArgs?: CommandArgs) => Promise<Command[]>;
getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, channelId: string, rootId?: string) => Promise<AutocompleteSuggestion[]>;
getAutocompleteCommandsList: (teamId: string, page?: number, perPage?: number) => Promise<Command[]>;
executeCommand: (command: string, commandArgs?: CommandArgs) => Promise<CommandResponse>;
addCommand: (command: Command) => Promise<Command>;
@ -22,9 +22,9 @@ const ClientIntegrations = (superclass: any) => class extends superclass {
);
};
getCommandAutocompleteSuggestionsList = async (userInput: string, teamId: string, commandArgs: {}) => {
getCommandAutocompleteSuggestionsList = async (userInput: string, teamId: string, channelId: string, rootId?: string) => {
return this.doFetch(
`${this.getTeamRoute(teamId)}/commands/autocomplete_suggestions${buildQueryString({...commandArgs, user_input: userInput})}`,
`${this.getTeamRoute(teamId)}/commands/autocomplete_suggestions${buildQueryString({user_input: userInput, team_id: teamId, channel_id: channelId, root_id: rootId})}`,
{method: 'get'},
);
};

View file

@ -1,175 +0,0 @@
// 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,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import UserItem from '@components/user_item';
import {useTheme} from '@context/theme';
import {changeOpacity} from '@utils/theme';
import type UserModel from '@typings/database/models/servers/user';
type AtMentionItemProps = {
user: UserProfile | UserModel;
onPress?: (username: string) => void;
testID?: string;
}
const AtMentionItem = ({
user,
onPress,
testID,
}: AtMentionItemProps) => {
const insets = useSafeAreaInsets();
const theme = useTheme();
const completeMention = useCallback(() => {
onPress?.(user.username);
}, [user.username]);
return (
<TouchableWithFeedback
testID={testID}
key={user.id}
onPress={completeMention}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
style={{marginLeft: insets.left, marginRight: insets.right}}
type={'native'}
>
<UserItem
user={user}
testID='at_mention'
/>
</TouchableWithFeedback>
);
};
export default AtMentionItem;

View file

@ -12,6 +12,8 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import AtMention from './at_mention/';
import ChannelMention from './channel_mention/';
import EmojiSuggestion from './emoji_suggestion/';
import SlashSuggestion from './slash_suggestion/';
import AppSlashSuggestion from './slash_suggestion/app_slash_suggestion/';
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
@ -92,13 +94,14 @@ const Autocomplete = ({
const [showingAtMention, setShowingAtMention] = useState(false);
const [showingChannelMention, setShowingChannelMention] = useState(false);
const [showingEmoji, setShowingEmoji] = useState(false);
const [showingCommand, setShowingCommand] = useState(false);
const [showingAppCommand, setShowingAppCommand] = useState(false);
// const [showingCommand, setShowingCommand] = useState(false);
// const [showingAppCommand, setShowingAppCommand] = useState(false);
// const [showingDate, setShowingDate] = useState(false);
const hasElements = showingChannelMention || showingEmoji || showingAtMention; // || showingCommand || showingAppCommand || showingDate;
const appsTakeOver = false; // showingAppCommand;
const hasElements = showingChannelMention || showingEmoji || showingAtMention || showingCommand || showingAppCommand; // || showingDate;
const appsTakeOver = showingAppCommand;
const showCommands = !(showingChannelMention || showingEmoji || showingAtMention);
const maxListHeight = useMemo(() => {
if (maxHeightOverride) {
@ -151,15 +154,17 @@ const Autocomplete = ({
testID='autocomplete'
style={containerStyles}
>
{/* {isAppsEnabled && (
{isAppsEnabled && (
<AppSlashSuggestion
maxListHeight={maxListHeight}
updateValue={updateValue}
onResultCountChange={setShowingAppCommand}
onShowingChange={setShowingAppCommand}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
channelId={channelId}
rootId={rootId}
/>
)} */}
)}
{(!appsTakeOver || !isAppsEnabled) && (<>
<AtMention
cursorPosition={cursorPosition}
@ -192,14 +197,18 @@ const Autocomplete = ({
hasFilesAttached={hasFilesAttached}
/>
}
{/* <SlashSuggestion
{showCommands &&
<SlashSuggestion
maxListHeight={maxListHeight}
updateValue={updateValue}
onResultCountChange={setShowingCommand}
onShowingChange={setShowingCommand}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
channelId={channelId}
rootId={rootId}
/>
{(isSearch && enableDateSuggestion) &&
}
{/* {(isSearch && enableDateSuggestion) &&
<DateSuggestion
cursorPosition={cursorPosition}
updateValue={updateValue}

View file

@ -12,6 +12,8 @@ import {General} from '@constants';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import type ChannelModel from '@typings/database/models/servers/channel';
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
icon: {
@ -37,7 +39,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
});
type Props = {
channel: Channel;
channel: Channel | ChannelModel;
displayName?: string;
isBot: boolean;
isGuest: boolean;
@ -74,6 +76,8 @@ const ChannelMentionItem = ({
let component;
const isArchived = ('delete_at' in channel ? channel.delete_at : channel.deleteAt) > 0;
if (channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL) {
if (!displayName) {
return null;
@ -112,7 +116,7 @@ const ChannelMentionItem = ({
shared={channel.shared}
type={channel.type}
isInfo={true}
isArchived={channel.delete_at > 0}
isArchived={isArchived}
size={18}
style={style.icon}
/>

View file

@ -12,21 +12,24 @@ import {observeUser} from '@queries/servers/user';
import ChannelMentionItem from './channel_mention_item';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type UserModel from '@typings/database/models/servers/user';
type OwnProps = {
channel: Channel;
channel: Channel | ChannelModel;
}
const enhanced = withObservables([], ({database, channel}: WithDatabaseArgs & OwnProps) => {
let user = of$<UserModel | undefined>(undefined);
if (channel.type === General.DM_CHANNEL) {
user = observeUser(database, channel.teammate_id!);
const teammateId = 'teammate_id' in channel ? channel.teammate_id : '';
const channelDisplayName = 'display_name' in channel ? channel.display_name : channel.displayName;
if (channel.type === General.DM_CHANNEL && teammateId) {
user = observeUser(database, teammateId!);
}
const isBot = user.pipe(switchMap((u) => of$(u ? u.isBot : false)));
const isGuest = user.pipe(switchMap((u) => of$(u ? u.isGuest : false)));
const displayName = user.pipe(switchMap((u) => of$(u ? u.username : channel.display_name)));
const displayName = user.pipe(switchMap((u) => of$(u ? u.username : channelDisplayName)));
return {
isBot,

View file

@ -0,0 +1,105 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {searchChannels} from '@actions/remote/channel';
import {searchUsers} from '@actions/remote/user';
import {COMMAND_SUGGESTION_CHANNEL, COMMAND_SUGGESTION_USER} from '@constants/apps';
export async function inTextMentionSuggestions(serverUrl: string, pretext: string, channelID: string, teamID: string, delimiter = ''): Promise<AutocompleteSuggestion[] | null> {
const separatedWords = pretext.split(' ');
const incompleteLessLastWord = separatedWords.slice(0, -1).join(' ');
const lastWord = separatedWords[separatedWords.length - 1];
if (lastWord.startsWith('@')) {
const res = await searchUsers(serverUrl, lastWord.substring(1), channelID);
const users = await getUserSuggestions(res.users);
users.forEach((u) => {
let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + u.Complete : u.Complete;
if (delimiter) {
complete = delimiter + complete;
}
u.Complete = complete;
});
return users;
}
if (lastWord.startsWith('~') && !lastWord.startsWith('~~')) {
const res = await searchChannels(serverUrl, lastWord.substring(1));
const channels = await getChannelSuggestions(res.channels);
channels.forEach((c) => {
let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + c.Complete : c.Complete;
if (delimiter) {
complete = delimiter + complete;
}
c.Complete = complete;
});
return channels;
}
return null;
}
export async function getUserSuggestions(usersAutocomplete?: {users: UserProfile[]; out_of_channel?: UserProfile[]}): Promise<AutocompleteSuggestion[]> {
const notFoundSuggestions = [{
Complete: '',
Suggestion: '',
Description: 'No user found',
Hint: '',
IconData: '',
}];
if (!usersAutocomplete) {
return notFoundSuggestions;
}
if (!usersAutocomplete.users.length && !usersAutocomplete.out_of_channel?.length) {
return notFoundSuggestions;
}
const items: AutocompleteSuggestion[] = [];
usersAutocomplete.users.forEach((u) => {
items.push(getUserSuggestion(u));
});
usersAutocomplete.out_of_channel?.forEach((u) => {
items.push(getUserSuggestion(u));
});
return items;
}
export async function getChannelSuggestions(channels?: Channel[]): Promise<AutocompleteSuggestion[]> {
const notFoundSuggestion = [{
Complete: '',
Suggestion: '',
Description: 'No channel found',
Hint: '',
IconData: '',
}];
if (!channels?.length) {
return notFoundSuggestion;
}
const items = channels.map((c) => {
return {
Complete: '~' + c.name,
Suggestion: '',
Description: '',
Hint: '',
IconData: '',
type: COMMAND_SUGGESTION_CHANNEL,
item: c,
};
});
return items;
}
function getUserSuggestion(u: UserProfile) {
return {
Complete: '@' + u.username,
Suggestion: '',
Description: '',
Hint: '',
IconData: '',
type: COMMAND_SUGGESTION_USER,
item: u,
};
}

View file

@ -0,0 +1,204 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {FlatList, Platform} from 'react-native';
import AtMentionItem from '@components/autocomplete/at_mention_item';
import ChannelMentionItem from '@components/autocomplete/channel_mention_item';
import {COMMAND_SUGGESTION_CHANNEL, COMMAND_SUGGESTION_USER} from '@constants/apps';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import analytics from '@init/analytics';
import ChannelModel from '@typings/database/models/servers/channel';
import UserModel from '@typings/database/models/servers/user';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {AppCommandParser, ExtendedAutocompleteSuggestion} from '../app_command_parser/app_command_parser';
import SlashSuggestionItem from '../slash_suggestion_item';
export type Props = {
currentTeamId: string;
isSearch?: boolean;
maxListHeight?: number;
updateValue: (text: string) => void;
onShowingChange: (c: boolean) => void;
value: string;
nestedScrollEnabled?: boolean;
rootId?: string;
channelId: string;
isAppsEnabled: boolean;
};
const keyExtractor = (item: ExtendedAutocompleteSuggestion): string => item.Suggestion + item.type + item.item;
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
listView: {
flex: 1,
backgroundColor: theme.centerChannelBg,
paddingTop: 8,
borderRadius: 4,
},
};
});
const emptySuggestonList: AutocompleteSuggestion[] = [];
const AppSlashSuggestion = ({
channelId,
currentTeamId,
rootId,
value = '',
isAppsEnabled,
maxListHeight,
nestedScrollEnabled,
updateValue,
onShowingChange,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const serverUrl = useServerUrl();
const appCommandParser = useRef<AppCommandParser>(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme));
const [dataSource, setDataSource] = useState<AutocompleteSuggestion[]>(emptySuggestonList);
const active = isAppsEnabled && Boolean(dataSource.length);
const style = getStyleFromTheme(theme);
const mounted = useRef(false);
const listStyle = useMemo(() => [style.listView, {maxHeight: maxListHeight}], [maxListHeight, style]);
const fetchAndShowAppCommandSuggestions = useMemo(() => debounce(async (pretext: string, cId: string, tId = '', rId?: string) => {
appCommandParser.current.setChannelContext(cId, tId, rId);
const suggestions = await appCommandParser.current.getSuggestions(pretext);
if (!mounted.current) {
return;
}
updateSuggestions(suggestions);
}), []);
const updateSuggestions = (matches: ExtendedAutocompleteSuggestion[]) => {
setDataSource(matches);
onShowingChange(Boolean(matches.length));
};
const completeSuggestion = useCallback((command: string) => {
analytics.get(serverUrl)?.trackCommand('complete_suggestion', `/${command} `);
// We are going to set a double / on iOS to prevent the auto correct from taking over and replacing it
// with the wrong value, this is a hack but I could not found another way to solve it
let completedDraft = `/${command} `;
if (Platform.OS === 'ios') {
completedDraft = `//${command} `;
}
updateValue(completedDraft);
if (Platform.OS === 'ios') {
// This is the second part of the hack were we replace the double / with just one
// after the auto correct vanished
setTimeout(() => {
updateValue(completedDraft.replace(`//${command} `, `/${command} `));
});
}
}, [serverUrl, updateValue]);
const completeIgnoringSuggestion = useCallback((base: string): (toIgnore: string) => void => {
return () => {
completeSuggestion(base);
};
}, [completeSuggestion]);
const renderItem = useCallback(({item}: {item: ExtendedAutocompleteSuggestion}) => {
switch (item.type) {
case COMMAND_SUGGESTION_USER:
if (!item.item) {
return null;
}
return (
<AtMentionItem
user={item.item as UserProfile | UserModel}
onPress={completeIgnoringSuggestion(item.Complete)}
testID={`autocomplete.at_mention.item.${item.item}`}
/>
);
case COMMAND_SUGGESTION_CHANNEL:
if (!item.item) {
return null;
}
return (
<ChannelMentionItem
channel={item.item as Channel | ChannelModel}
onPress={completeIgnoringSuggestion(item.Complete)}
testID={`autocomplete.channel_mention.item.${item.item}`}
/>
);
default:
return (
<SlashSuggestionItem
description={item.Description}
hint={item.Hint}
onPress={completeSuggestion}
suggestion={item.Suggestion}
complete={item.Complete}
icon={item.IconData}
/>
);
}
}, [completeSuggestion, completeIgnoringSuggestion]);
const isAppCommand = (pretext: string, channelID: string, teamID = '', rootID?: string) => {
appCommandParser.current.setChannelContext(channelID, teamID, rootID);
return appCommandParser.current.isAppCommand(pretext);
};
useEffect(() => {
if (value[0] !== '/') {
fetchAndShowAppCommandSuggestions.cancel();
updateSuggestions(emptySuggestonList);
return;
}
if (value.indexOf(' ') === -1) {
// Let slash command suggestions handle base commands.
fetchAndShowAppCommandSuggestions.cancel();
updateSuggestions(emptySuggestonList);
return;
}
if (!isAppCommand(value, channelId, currentTeamId, rootId)) {
fetchAndShowAppCommandSuggestions.cancel();
updateSuggestions(emptySuggestonList);
return;
}
fetchAndShowAppCommandSuggestions(value, channelId, currentTeamId, rootId);
}, [value]);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
if (!active) {
// If we are not in an active state return null so nothing is rendered
// other components are not blocked.
return null;
}
return (
<FlatList
testID='app_slash_suggestion.list'
keyboardShouldPersistTaps='always'
style={listStyle}
data={dataSource}
keyExtractor={keyExtractor}
removeClippedSubviews={true}
renderItem={renderItem}
nestedScrollEnabled={nestedScrollEnabled}
/>
);
};
export default AppSlashSuggestion;

View file

@ -0,0 +1,18 @@
// 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 {observeConfigBooleanValue, observeCurrentTeamId} from '@queries/servers/system';
import AppSlashSuggestion from './app_slash_suggestion';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
currentTeamId: observeCurrentTeamId(database),
isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'),
}));
export default withDatabase(enhanced(AppSlashSuggestion));

View file

@ -0,0 +1,18 @@
// 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 {observeConfigBooleanValue, observeCurrentTeamId} from '@queries/servers/system';
import SlashSuggestion from './slash_suggestion';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
currentTeamId: observeCurrentTeamId(database),
isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'),
}));
export default withDatabase(enhanced(SlashSuggestion));

View file

@ -0,0 +1,247 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {
FlatList,
Platform,
} from 'react-native';
import {fetchSuggestions} from '@actions/remote/command';
import IntegrationsManager from '@app/init/integrations_manager';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import analytics from '@init/analytics';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {AppCommandParser} from './app_command_parser/app_command_parser';
import SlashSuggestionItem from './slash_suggestion_item';
// TODO: Remove when all below commands have been implemented
const COMMANDS_TO_IMPLEMENT_LATER = ['collapse', 'expand', 'join', 'open', 'leave', 'logout', 'msg', 'grpmsg'];
const NON_MOBILE_COMMANDS = ['rename', 'invite_people', 'shortcuts', 'search', 'help', 'settings', 'remove'];
const COMMANDS_TO_HIDE_ON_MOBILE = [...COMMANDS_TO_IMPLEMENT_LATER, ...NON_MOBILE_COMMANDS];
const commandFilter = (v: Command) => !COMMANDS_TO_HIDE_ON_MOBILE.includes(v.trigger);
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
listView: {
flex: 1,
backgroundColor: theme.centerChannelBg,
paddingTop: 8,
borderRadius: 4,
},
};
});
const filterCommands = (matchTerm: string, commands: Command[]): AutocompleteSuggestion[] => {
const data = commands.filter((command) => {
if (!command.auto_complete) {
return false;
} else if (!matchTerm) {
return true;
}
return command.display_name.startsWith(matchTerm) || command.trigger.startsWith(matchTerm);
});
return data.map((command) => {
return {
Complete: command.trigger,
Suggestion: '/' + command.trigger,
Hint: command.auto_complete_hint,
Description: command.auto_complete_desc,
IconData: command.icon_url || command.autocomplete_icon_data || '',
};
});
};
const keyExtractor = (item: Command & AutocompleteSuggestion): string => item.id || item.Suggestion;
type Props = {
currentTeamId: string;
maxListHeight?: number;
updateValue: (text: string) => void;
onShowingChange: (c: boolean) => void;
value: string;
nestedScrollEnabled?: boolean;
rootId?: string;
channelId: string;
isAppsEnabled: boolean;
};
const emptyCommandList: Command[] = [];
const emptySuggestionList: AutocompleteSuggestion[] = [];
const SlashSuggestion = ({
channelId,
currentTeamId,
rootId,
onShowingChange,
isAppsEnabled,
maxListHeight,
nestedScrollEnabled,
updateValue,
value = '',
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleFromTheme(theme);
const serverUrl = useServerUrl();
const appCommandParser = useRef<AppCommandParser>(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme));
const mounted = useRef(false);
const [noResultsTerm, setNoResultsTerm] = useState<string|null>(null);
const [dataSource, setDataSource] = useState<AutocompleteSuggestion[]>(emptySuggestionList);
const [commands, setCommands] = useState<Command[]>();
const active = Boolean(dataSource.length);
const listStyle = useMemo(() => [style.listView, {maxHeight: maxListHeight}], [maxListHeight, style]);
const updateSuggestions = useCallback((matches: AutocompleteSuggestion[]) => {
setDataSource(matches);
onShowingChange(Boolean(matches.length));
}, [onShowingChange]);
const runFetch = useMemo(() => debounce(async (sUrl: string, term: string, tId: string, cId: string, rId?: string) => {
try {
const res = await fetchSuggestions(sUrl, term, tId, cId, rId);
if (!mounted.current) {
return;
}
if (res.error) {
updateSuggestions(emptySuggestionList);
} else if (res.suggestions.length === 0) {
updateSuggestions(emptySuggestionList);
setNoResultsTerm(term);
} else {
updateSuggestions(res.suggestions);
}
} catch {
updateSuggestions(emptySuggestionList);
}
}, 200), [updateSuggestions]);
const getAppBaseCommandSuggestions = (pretext: string): AutocompleteSuggestion[] => {
appCommandParser.current.setChannelContext(channelId, currentTeamId, rootId);
const suggestions = appCommandParser.current.getSuggestionsBase(pretext);
return suggestions;
};
const showBaseCommands = (text: string) => {
let matches: AutocompleteSuggestion[] = [];
if (isAppsEnabled) {
const appCommands = getAppBaseCommandSuggestions(text);
matches = matches.concat(appCommands);
}
matches = matches.concat(filterCommands(text.substring(1), commands!));
matches.sort((match1, match2) => {
if (match1.Suggestion === match2.Suggestion) {
return 0;
}
return match1.Suggestion > match2.Suggestion ? 1 : -1;
});
updateSuggestions(matches);
};
const completeSuggestion = useCallback((command: string) => {
analytics.get(serverUrl)?.trackCommand('complete_suggestion', `/${command} `);
// We are going to set a double / on iOS to prevent the auto correct from taking over and replacing it
// with the wrong value, this is a hack but I could not found another way to solve it
let completedDraft = `/${command} `;
if (Platform.OS === 'ios') {
completedDraft = `//${command} `;
}
updateValue(completedDraft);
if (Platform.OS === 'ios') {
// This is the second part of the hack were we replace the double / with just one
// after the auto correct vanished
setTimeout(() => {
updateValue(completedDraft.replace(`//${command} `, `/${command} `));
});
}
}, [updateValue, serverUrl]);
const renderItem = useCallback(({item}: {item: AutocompleteSuggestion}) => (
<SlashSuggestionItem
description={item.Description}
hint={item.Hint}
onPress={completeSuggestion}
suggestion={item.Suggestion}
complete={item.Complete}
icon={item.IconData}
/>
), [completeSuggestion]);
useEffect(() => {
if (value[0] !== '/') {
runFetch.cancel();
setNoResultsTerm(null);
updateSuggestions(emptySuggestionList);
return;
}
if (!commands) {
IntegrationsManager.getManager(serverUrl).fetchCommands(currentTeamId).then((res) => {
if (res.length) {
setCommands(res.filter(commandFilter));
} else {
setCommands(emptyCommandList);
}
});
return;
}
if (value.indexOf(' ') === -1) {
runFetch.cancel();
showBaseCommands(value);
setNoResultsTerm(null);
return;
}
if (noResultsTerm && value.startsWith(noResultsTerm)) {
return;
}
runFetch(serverUrl, value, currentTeamId, channelId, rootId);
}, [value, commands]);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
if (!active) {
// If we are not in an active state return null so nothing is rendered
// other components are not blocked.
return null;
}
return (
<FlatList
testID='slash_suggestion.list'
keyboardShouldPersistTaps='always'
style={listStyle}
data={dataSource}
keyExtractor={keyExtractor}
removeClippedSubviews={true}
renderItem={renderItem}
nestedScrollEnabled={nestedScrollEnabled}
/>
);
};
export default SlashSuggestion;

View file

@ -0,0 +1,182 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import base64 from 'base-64';
import React, {useCallback, useMemo} from 'react';
import {Image, Text, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {SvgXml} from 'react-native-svg';
import CompassIcon from '@app/components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {COMMAND_SUGGESTION_ERROR} from '@constants/apps';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const slashIcon = require('@assets/images/autocomplete/slash_command.png');
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
icon: {
fontSize: 24,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
width: 35,
height: 35,
marginRight: 12,
borderRadius: 4,
justifyContent: 'center',
alignItems: 'center',
marginTop: 8,
},
uriIcon: {
width: 16,
height: 16,
},
iconColor: {
tintColor: theme.centerChannelColor,
},
container: {
flexDirection: 'row',
alignItems: 'center',
paddingBottom: 8,
paddingHorizontal: 16,
overflow: 'hidden',
},
suggestionContainer: {
flex: 1,
},
suggestionDescription: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.56),
},
suggestionName: {
fontSize: 15,
color: theme.centerChannelColor,
marginBottom: 4,
},
};
});
type Props = {
complete: string;
description: string;
hint: string;
onPress: (complete: string) => void;
suggestion: string;
icon: string;
}
const SlashSuggestionItem = ({
complete = '',
description,
hint,
onPress,
suggestion,
icon,
}: Props) => {
const insets = useSafeAreaInsets();
const theme = useTheme();
const style = getStyleFromTheme(theme);
const iconAsSource = useMemo(() => {
return {uri: icon};
}, [icon]);
const touchableStyle = useMemo(() => {
return {marginLeft: insets.left, marginRight: insets.right};
}, [insets]);
const completeSuggestion = useCallback(() => {
onPress(complete);
}, [onPress, complete]);
let suggestionText = suggestion;
if (suggestionText?.[0] === '/' && complete.split(' ').length === 1) {
suggestionText = suggestionText.substring(1);
}
if (hint) {
if (suggestionText?.length) {
suggestionText += ` ${hint}`;
} else {
suggestionText = hint;
}
}
let image = (
<Image
style={style.iconColor}
width={10}
height={16}
source={slashIcon}
/>
);
if (icon === COMMAND_SUGGESTION_ERROR) {
image = (
<CompassIcon
name='alert-circle-outline'
size={24}
/>
);
} else if (icon.startsWith('http')) {
image = (
<FastImage
source={iconAsSource}
style={style.uriIcon}
/>
);
} else if (icon.startsWith('data:')) {
if (icon.startsWith('data:image/svg+xml')) {
let xml = '';
try {
xml = base64.decode(icon.substring('data:image/svg+xml;base64,'.length));
image = (
<SvgXml
xml={xml}
width={32}
height={32}
/>
);
} catch (error) {
// Do nothing
}
} else {
image = (
<Image
source={iconAsSource}
style={style.uriIcon}
/>
);
}
}
return (
<TouchableWithFeedback
onPress={completeSuggestion}
style={touchableStyle}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
type={'native'}
>
<View style={style.container}>
<View style={style.icon}>
{image}
</View>
<View style={style.suggestionContainer}>
<Text style={style.suggestionName}>{`${suggestionText}`}</Text>
{Boolean(description) &&
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={style.suggestionDescription}
>
{description}
</Text>
}
</View>
</View>
</TouchableWithFeedback>
);
};
export default SlashSuggestionItem;

View file

@ -13,6 +13,7 @@ Object {
Object {
"value": Object {
"height": 40,
"marginVertical": 2,
"opacity": 1,
},
}
@ -21,6 +22,7 @@ Object {
style={
Object {
"height": 40,
"marginVertical": 2,
"opacity": 1,
}
}
@ -46,10 +48,10 @@ Object {
<View
style={
Object {
"alignItems": "center",
"flexDirection": "row",
"marginBottom": 8,
"height": 40,
"paddingLeft": 2,
"paddingVertical": 4,
}
}
>

View file

@ -6,6 +6,7 @@ exports[`components/channel_list/categories/body/channel/item should match snaps
Object {
"value": Object {
"height": 40,
"marginVertical": 2,
"opacity": 1,
},
}
@ -14,6 +15,7 @@ exports[`components/channel_list/categories/body/channel/item should match snaps
style={
Object {
"height": 40,
"marginVertical": 2,
"opacity": 1,
}
}
@ -39,10 +41,10 @@ exports[`components/channel_list/categories/body/channel/item should match snaps
<View
style={
Object {
"alignItems": "center",
"flexDirection": "row",
"marginBottom": 8,
"height": 40,
"paddingLeft": 2,
"paddingVertical": 4,
}
}
>

View file

@ -8,6 +8,7 @@ import {TouchableOpacity} from 'react-native-gesture-handler';
import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {switchToChannelById} from '@actions/remote/channel';
import Badge from '@components/badge';
import ChannelIcon from '@components/channel_icon';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
@ -21,9 +22,9 @@ import type MyChannelModel from '@typings/database/models/servers/my_channel';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flexDirection: 'row',
marginBottom: 8,
paddingLeft: 2,
paddingVertical: 4,
height: 40,
alignItems: 'center',
},
icon: {
fontSize: 24,
@ -42,6 +43,16 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
muted: {
color: changeOpacity(theme.sidebarText, 0.4),
},
badge: {
position: 'relative',
borderWidth: 0,
left: 0,
top: 0,
alignSelf: undefined,
},
mutedBadge: {
opacity: 0.4,
},
}));
const textStyle = StyleSheet.create({
@ -65,7 +76,7 @@ const ChannelListItem = ({channel, isActive, isOwnDirectMessage, isMuted, myChan
const serverUrl = useServerUrl();
// Make it brighter if it's not muted, and highlighted or has unreads
const bright = !isMuted && myChannel && (myChannel.isUnread || myChannel.mentionsCount > 0);
const bright = !isMuted && (isActive || (myChannel && (myChannel.isUnread || myChannel.mentionsCount > 0)));
const sharedValue = useSharedValue(collapsed && !bright);
@ -75,6 +86,7 @@ const ChannelListItem = ({channel, isActive, isOwnDirectMessage, isMuted, myChan
const animatedStyle = useAnimatedStyle(() => {
return {
marginVertical: withTiming(sharedValue.value ? 0 : 2, {duration: 500}),
height: withTiming(sharedValue.value ? 0 : 40, {duration: 500}),
opacity: withTiming(sharedValue.value ? 0 : 1, {duration: 500, easing: Easing.inOut(Easing.exp)}),
};
@ -89,7 +101,6 @@ const ChannelListItem = ({channel, isActive, isOwnDirectMessage, isMuted, myChan
if (channel.type === General.GM_CHANNEL) {
return channel.displayName?.split(',').length;
}
return 0;
}, [channel.type, channel.displayName]);
@ -121,6 +132,7 @@ const ChannelListItem = ({channel, isActive, isOwnDirectMessage, isMuted, myChan
shared={channel.shared}
size={24}
type={channel.type}
isMuted={isMuted}
/>
<Text
ellipsizeMode='tail'
@ -129,7 +141,11 @@ const ChannelListItem = ({channel, isActive, isOwnDirectMessage, isMuted, myChan
>
{displayName}
</Text>
<Badge
visible={myChannel.mentionsCount > 0}
value={myChannel.mentionsCount}
style={[styles.badge, isMuted && styles.mutedBadge]}
/>
</View>
</TouchableOpacity>
</Animated.View>

View file

@ -9,7 +9,7 @@ import CategoryBody from './body';
import LoadCategoriesError from './error';
import CategoryHeader from './header';
import type {CategoryModel} from '@database/models/server';
import type CategoryModel from '@typings/database/models/servers/category';
type Props = {
categories: CategoryModel[];

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import React, {useCallback, useMemo} from 'react';
import {TouchableOpacity, View} from 'react-native';
import AnimatedNumbers from 'react-native-animated-numbers';
@ -14,17 +14,20 @@ type ReactionProps = {
emojiName: string;
highlight: boolean;
onPress: (emojiName: string, highlight: boolean) => void;
onLongPress: () => void;
onLongPress: (initialEmoji: string) => void;
theme: Theme;
}
const MIN_WIDTH = 50;
const DIGIT_WIDTH = 5;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
count: {
color: changeOpacity(theme.centerChannelColor, 0.56),
...typography('Body', 100, 'SemiBold'),
},
countContainer: {marginRight: 5},
countContainer: {marginRight: 8},
countHighlight: {
color: theme.buttonBg,
},
@ -44,13 +47,22 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
justifyContent: 'center',
marginBottom: 12,
marginRight: 8,
minWidth: 50,
minWidth: MIN_WIDTH,
},
};
});
const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: ReactionProps) => {
const styles = getStyleSheet(theme);
const digits = String(count).length;
const containerStyle = useMemo(() => {
const minWidth = MIN_WIDTH + (digits * DIGIT_WIDTH);
return [styles.reaction, (highlight && styles.highlight), {minWidth}];
}, [styles.reaction, highlight, digits]);
const handleLongPress = useCallback(() => {
onLongPress(emojiName);
}, []);
const handlePress = useCallback(() => {
onPress(emojiName, highlight);
@ -59,9 +71,9 @@ const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: Re
return (
<TouchableOpacity
onPress={handlePress}
onLongPress={onLongPress}
onLongPress={handleLongPress}
delayLongPress={350}
style={[styles.reaction, (highlight && styles.highlight)]}
style={containerStyle}
>
<View style={styles.emoji}>
<Emoji

View file

@ -3,13 +3,16 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {TouchableOpacity, View} from 'react-native';
import {Keyboard, TouchableOpacity, View} from 'react-native';
import {addReaction, removeReaction} from '@actions/remote/reactions';
import CompassIcon from '@components/compass_icon';
import {Screens} from '@constants';
import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
import {useServerUrl} from '@context/server';
import {showModal, showModalOverCurrentContext} from '@screens/navigation';
import {useIsTablet} from '@hooks/device';
import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {getEmojiFirstAlias} from '@utils/emoji/helpers';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -58,13 +61,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, postId, reactions, theme}: ReactionsProps) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const isTablet = useIsTablet();
const pressed = useRef(false);
const [sortedReactions, setSortedReactions] = useState(new Set(reactions.map((r) => r.emojiName)));
const [sortedReactions, setSortedReactions] = useState(new Set(reactions.map((r) => getEmojiFirstAlias(r.emojiName))));
const styles = getStyleSheet(theme);
useEffect(() => {
// This helps keep the reactions in the same position at all times until unmounted
const rs = reactions.map((r) => r.emojiName);
const rs = reactions.map((r) => getEmojiFirstAlias(r.emojiName));
const sorted = new Set([...sortedReactions]);
const added = rs.filter((r) => !sorted.has(r));
added.forEach(sorted.add, sorted);
@ -78,14 +82,20 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
const reactionsByName = reactions.reduce((acc, reaction) => {
if (reaction) {
if (acc.has(reaction.emojiName)) {
acc.get(reaction.emojiName)!.push(reaction);
const emojiAlias = getEmojiFirstAlias(reaction.emojiName);
if (acc.has(emojiAlias)) {
const rs = acc.get(emojiAlias);
// eslint-disable-next-line max-nested-callbacks
const present = rs!.findIndex((r) => r.userId === reaction.userId) > -1;
if (!present) {
rs!.push(reaction);
}
} else {
acc.set(reaction.emojiName, [reaction]);
acc.set(emojiAlias, [reaction]);
}
if (reaction.userId === currentUserId) {
highlightedReactions.push(reaction.emojiName);
highlightedReactions.push(emojiAlias);
}
}
@ -99,8 +109,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
addReaction(serverUrl, postId, emoji);
};
const handleAddReaction = preventDoubleTap(() => {
const screen = 'AddReaction';
const handleAddReaction = useCallback(preventDoubleTap(() => {
const title = intl.formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'});
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
@ -109,10 +118,10 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
onEmojiPress: handleAddReactionToPost,
};
showModal(screen, title, passProps);
});
showModal(Screens.EMOJI_PICKER, title, passProps);
}), [intl, theme]);
const handleReactionPress = async (emoji: string, remove: boolean) => {
const handleReactionPress = useCallback(async (emoji: string, remove: boolean) => {
pressed.current = true;
if (remove && canRemoveReaction && !disabled) {
await removeReaction(serverUrl, postId, emoji);
@ -121,18 +130,26 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
}
pressed.current = false;
};
}, [canRemoveReaction, canAddReaction, disabled]);
const showReactionList = () => {
const screen = 'ReactionList';
const showReactionList = useCallback((initialEmoji: string) => {
const screen = Screens.REACTIONS;
const passProps = {
initialEmoji,
postId,
};
Keyboard.dismiss();
const title = isTablet ? intl.formatMessage({id: 'post.reactions.title', defaultMessage: 'Reactions'}) : '';
if (!pressed.current) {
showModalOverCurrentContext(screen, passProps);
if (isTablet) {
showModal(screen, title, passProps, bottomSheetModalOptions(theme, 'close-post-reactions'));
} else {
showModalOverCurrentContext(screen, passProps);
}
}
};
}, [intl, isTablet, postId, theme]);
let addMoreReactions = null;
const {reactionsByName, highlightedReactions} = buildReactionsMap();
@ -175,4 +192,4 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
);
};
export default React.memo(Reactions);
export default Reactions;

View file

@ -8,7 +8,7 @@ import {map, switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {getPreferenceAsBool, getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {queryPostsInThread} from '@queries/servers/post';
import {queryPostRepliesCount} from '@queries/servers/post';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeConfig, observeLicense} from '@queries/servers/system';
import {isMinimumServerVersion} from '@utils/helpers';
@ -37,7 +37,7 @@ const withHeaderProps = withObservables(
const teammateNameDisplay = combineLatest([preferences, config, license]).pipe(
map(([prefs, cfg, lcs]) => getTeammateNameDisplaySetting(prefs, cfg, lcs)),
);
const commentCount = queryPostsInThread(database, post.rootId || post.id).observeCount();
const commentCount = queryPostRepliesCount(database, post.rootId || post.id).observeCount();
const rootPostAuthor = differentThreadSequence ? post.root.observe().pipe(switchMap((root) => {
if (root.length) {
return root[0].author.observe();

View file

@ -7,7 +7,7 @@ import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {observePost, queryPostsInThread} from '@queries/servers/post';
import {observePost, queryPostRepliesCount} from '@queries/servers/post';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import ThreadOverview from './thread_overview';
@ -24,7 +24,7 @@ const enhanced = withObservables(
pipe(
switchMap((pref) => of$(Boolean(pref[0]?.value === 'true'))),
),
repliesCount: queryPostsInThread(database, rootId).observeCount(),
repliesCount: queryPostRepliesCount(database, rootId).observeCount(false),
};
});

View file

@ -8,7 +8,7 @@ import {switchMap} from 'rxjs/operators';
import {observeConfig, observeCurrentUserId} from '@queries/servers/system';
import AtMentionItem from './at_mention_item';
import UserItem from './user_item';
import type {WithDatabaseArgs} from '@typings/database/database';
@ -28,4 +28,4 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
};
});
export default withDatabase(enhanced(AtMentionItem));
export default withDatabase(enhanced(UserItem));

View file

@ -0,0 +1,174 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {StyleProp, Text, View, ViewStyle} from 'react-native';
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 {General} from '@constants';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {getUserCustomStatus, isBot, isGuest, isShared} from '@utils/user';
import type UserModel from '@typings/database/models/servers/user';
type AtMentionItemProps = {
user?: UserProfile | UserModel;
containerStyle?: StyleProp<ViewStyle>;
currentUserId: string;
showFullName: boolean;
testID?: string;
isCustomStatusEnabled: boolean;
}
const getName = (user: UserProfile | UserModel | undefined, showFullName: boolean, isCurrentUser: boolean, intl: IntlShape) => {
let name = '';
if (!user) {
return intl.formatMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'});
}
const hasNickname = user.nickname.length > 0;
if (showFullName) {
const first = 'first_name' in user ? user.first_name : user.firstName;
const last = 'last_name' in user ? user.last_name : user.lastName;
name += `${first} ${last} `;
}
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,
fontFamily: 'OpenSans',
paddingLeft: 4,
flexShrink: 1,
},
rowUsername: {
color: changeOpacity(theme.centerChannelColor, 0.56),
fontSize: 15,
fontFamily: 'OpenSans',
flexShrink: 5,
},
icon: {
marginLeft: 4,
},
};
});
const UserItem = ({
containerStyle,
user,
currentUserId,
showFullName,
testID,
isCustomStatusEnabled,
}: AtMentionItemProps) => {
const theme = useTheme();
const style = getStyleFromTheme(theme);
const intl = useIntl();
const bot = user ? isBot(user) : false;
const guest = user ? isGuest(user.roles) : false;
const shared = user ? isShared(user) : false;
const isCurrentUser = currentUserId === user?.id;
const name = getName(user, showFullName, isCurrentUser, intl);
const customStatus = getUserCustomStatus(user);
return (
<View style={[style.row, containerStyle]}>
<View style={style.rowPicture}>
<ProfilePicture
author={user}
size={24}
showStatus={false}
testID={`${testID}.profile_picture`}
/>
</View>
<View
style={[style.rowInfo, {maxWidth: shared ? '75%' : '80%'}]}
>
{bot && <BotTag/>}
{guest && <GuestTag/>}
{Boolean(name.length) &&
<Text
style={style.rowFullname}
numberOfLines={1}
testID={`${testID}.name`}
>
{name}
</Text>
}
{isCurrentUser &&
<FormattedText
id='suggestion.mention.you'
defaultMessage=' (you)'
style={style.rowUsername}
/>
}
{Boolean(user) &&
<Text
style={style.rowUsername}
numberOfLines={1}
testID='at_mention_item.username'
>
{` @${user!.username}`}
</Text>
}
</View>
{isCustomStatusEnabled && !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>
);
};
export default UserItem;

View file

@ -44,6 +44,10 @@ export const AppFieldTypes: { [name: string]: AppFieldType } = {
MARKDOWN: 'markdown',
};
export const COMMAND_SUGGESTION_ERROR = 'error';
export const COMMAND_SUGGESTION_CHANNEL = 'channel';
export const COMMAND_SUGGESTION_USER = 'user';
export default {
AppBindingLocations,
AppBindingPresentations,
@ -51,4 +55,7 @@ export default {
AppCallTypes,
AppExpandLevels,
AppFieldTypes,
COMMAND_SUGGESTION_ERROR,
COMMAND_SUGGESTION_CHANNEL,
COMMAND_SUGGESTION_USER,
};

View file

@ -3,7 +3,7 @@
export const ABOUT = 'About';
export const ACCOUNT = 'Account';
export const EMOJI_PICKER = 'AddReaction';
export const EMOJI_PICKER = 'EmojiPicker';
export const APP_FORM = 'AppForm';
export const BOTTOM_SHEET = 'BottomSheet';
export const BROWSE_CHANNELS = 'BrowseChannels';
@ -11,6 +11,7 @@ export const CHANNEL = 'Channel';
export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople';
export const CHANNEL_DETAILS = 'ChannelDetails';
export const CHANNEL_EDIT = 'ChannelEdit';
export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage';
export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter';
export const CUSTOM_STATUS = 'CustomStatus';
export const EDIT_POST = 'EditPost';
@ -24,16 +25,16 @@ 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 POST_OPTIONS = 'PostOptions';
export const REACTIONS = 'Reactions';
export const SAVED_POSTS = 'SavedPosts';
export const SEARCH = 'Search';
export const SERVER = 'Server';
export const SETTINGS_SIDEBAR = 'SettingsSidebar';
export const SSO = 'SSO';
export const THREAD = 'Thread';
export const USER_PROFILE = 'UserProfile';
export const POST_OPTIONS = 'PostOptions';
export const SAVED_POSTS = 'SavedPosts';
export default {
ABOUT,
@ -46,6 +47,7 @@ export default {
CHANNEL_ADD_PEOPLE,
CHANNEL_EDIT,
CHANNEL_DETAILS,
CREATE_DIRECT_MESSAGE,
CUSTOM_STATUS_CLEAR_AFTER,
CUSTOM_STATUS,
EDIT_POST,
@ -59,14 +61,21 @@ export default {
LOGIN,
MENTIONS,
MFA,
CREATE_DIRECT_MESSAGE,
PERMALINK,
POST_OPTIONS,
REACTIONS,
SAVED_POSTS,
SEARCH,
SERVER,
SETTINGS_SIDEBAR,
SSO,
THREAD,
USER_PROFILE,
POST_OPTIONS,
SAVED_POSTS,
};
export const MODAL_SCREENS_WITHOUT_BACK = [
CREATE_DIRECT_MESSAGE,
EMOJI_PICKER,
EDIT_POST,
PERMALINK,
];

View file

@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchCommands} from '@actions/remote/command';
const TIME_TO_REFETCH_COMMANDS = 60000; // 1 minute
class ServerIntegrationsManager {
private serverUrl: string;
private commandsLastFetched: {[teamId: string]: number | undefined} = {};
private commands: {[teamId: string]: Command[] | undefined} = {};
private triggerId = '';
private bindings: AppBinding[] = [];
private rhsBindings: AppBinding[] = [];
private commandForms: {[key: string]: AppForm | undefined} = {};
private rhsCommandForms: {[key: string]: AppForm | undefined} = {};
constructor(serverUrl: string) {
this.serverUrl = serverUrl;
}
public async fetchCommands(teamId: string) {
const lastFetched = this.commandsLastFetched[teamId] || 0;
const lastCommands = this.commands[teamId];
if (lastCommands && lastFetched + TIME_TO_REFETCH_COMMANDS > Date.now()) {
return lastCommands;
}
try {
const res = await fetchCommands(this.serverUrl, teamId);
if (res.error) {
return [];
}
this.commands[teamId] = res.commands;
this.commandsLastFetched[teamId] = Date.now();
return res.commands;
} catch {
return [];
}
}
public getCommandBindings() {
// TODO filter bindings
return this.bindings;
}
public getRHSCommandBindings() {
// TODO filter bindings
return this.rhsBindings;
}
public getAppRHSCommandForm(key: string) {
return this.rhsCommandForms[key];
}
public getAppCommandForm(key: string) {
return this.commandForms[key];
}
public setAppRHSCommandForm(key: string, form: AppForm) {
this.rhsCommandForms[key] = form;
}
public setAppCommandForm(key: string, form: AppForm) {
this.commandForms[key] = form;
}
public getTriggerId() {
return this.triggerId;
}
public setTriggerId(id: string) {
this.triggerId = id;
}
}
class IntegrationsManager {
private serverManagers: {[serverUrl: string]: ServerIntegrationsManager | undefined} = {};
public getManager(serverUrl: string): ServerIntegrationsManager {
if (!this.serverManagers[serverUrl]) {
this.serverManagers[serverUrl] = new ServerIntegrationsManager(serverUrl);
}
return this.serverManagers[serverUrl]!;
}
}
export default new IntegrationsManager();

View file

@ -75,6 +75,14 @@ export const queryPostsInThread = (database: Database, rootId: string, sorted =
return database.get<PostsInThreadModel>(POSTS_IN_THREAD).query(...clauses);
};
export const queryPostRepliesCount = (database: Database, rootId: string, excludeDeleted = true) => {
const clauses: Q.Clause[] = [Q.where('root_id', rootId)];
if (excludeDeleted) {
clauses.push(Q.where('delete_at', Q.eq(0)));
}
return database.get(POST).query(...clauses);
};
export const getRecentPostsInThread = async (database: Database, rootId: string) => {
const chunks = await queryPostsInThread(database, rootId, true, true).fetch();
if (chunks.length) {

View file

@ -145,6 +145,8 @@ const EditServerForm = ({
);
}
const saveButtonTestId = buttonDisabled ? 'edit_server_form.save.button.disabled' : 'edit_server_form.save.button';
return (
<View style={styles.formContainer}>
<View style={[styles.fullWidth, displayNameError?.length ? styles.error : undefined]}>
@ -164,7 +166,7 @@ const EditServerForm = ({
ref={displayNameRef}
returnKeyType='done'
spellCheck={false}
testID='select_server.server_display_name.input'
testID='edit_server_form.server_display_name.input'
theme={theme}
value={displayName}
/>
@ -174,7 +176,7 @@ const EditServerForm = ({
defaultMessage={'Server: {url}'}
id={'edit_server.display_help'}
style={styles.chooseText}
testID={'edit_server.display_help'}
testID={'edit_server_form.display_help'}
values={{url: removeProtocol(stripTrailingSlashes(serverUrl))}}
/>
}
@ -182,7 +184,7 @@ const EditServerForm = ({
containerStyle={[styles.connectButton, styleButtonBackground]}
disabled={buttonDisabled}
onPress={onUpdate}
testID='select_server.connect.button'
testID={saveButtonTestId}
>
{buttonIcon}
<FormattedText

View file

@ -40,13 +40,13 @@ const EditServerHeader = ({theme}: Props) => {
defaultMessage='Edit server name'
id='edit_server.title'
style={styles.title}
testID='edit_server.title'
testID='edit_server_header.title'
/>
<FormattedText
defaultMessage='Specify a display name for this server'
id='edit_server.description'
style={styles.description}
testID='edit_server.description'
testID='edit_server_header.description'
/>
</View>
);

View file

@ -99,7 +99,7 @@ const EditServer = ({closeButtonId, componentId, server, theme}: ServerProps) =>
<SafeAreaView
key={'server_content'}
style={styles.flex}
testID='select_server.screen'
testID='edit_server.screen'
>
<KeyboardAwareScrollView
bounces={false}

View file

@ -80,6 +80,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
require('@screens/custom_status_clear_after').default,
);
break;
case Screens.CREATE_DIRECT_MESSAGE:
screen = withServerDatabase(require('@screens/create_direct_message').default);
break;
case Screens.EDIT_POST:
screen = withServerDatabase(require('@screens/edit_post').default);
break;
@ -127,6 +130,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
require('@screens/post_options').default,
);
break;
case Screens.REACTIONS:
screen = withServerDatabase(require('@screens/reactions').default);
break;
case Screens.SAVED_POSTS:
screen = withServerDatabase((require('@screens/home/saved_posts').default));
break;

View file

@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text, View} from 'react-native';
import {useTheme} from '@context/theme';
import {getEmojiByName} from '@utils/emoji/helpers';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
emoji: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
marginBottom: 16,
},
title: {
color: theme.centerChannelColor,
...typography('Body', 75, 'SemiBold'),
},
}));
const EmojiAliases = ({emoji}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const aliases = getEmojiByName(emoji, [])?.short_names?.map((n: string) => `:${n}:`).join(' ') || `:${emoji}:`;
return (
<View style={style.container}>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={style.title}
>
{aliases}
</Text>
</View>
);
};
export default EmojiAliases;

View file

@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef} from 'react';
import {StyleSheet} from 'react-native';
import {FlatList} from 'react-native-gesture-handler';
import Item from './item';
import type ReactionModel from '@typings/database/models/servers/reaction';
type Props = {
emojiSelected: string;
reactionsByName: Map<string, ReactionModel[]>;
setIndex: (idx: number) => void;
sortedReactions: string[];
}
type ScrollIndexFailed = {
index: number;
highestMeasuredFrameIndex: number;
averageItemLength: number;
};
const style = StyleSheet.create({
container: {
maxHeight: 44,
},
});
const EmojiBar = ({emojiSelected, reactionsByName, setIndex, sortedReactions}: Props) => {
const listRef = useRef<FlatList<string>>(null);
const scrollToIndex = (index: number, animated = false) => {
listRef.current?.scrollToIndex({
animated,
index,
viewOffset: 0,
viewPosition: 1, // 0 is at bottom
});
};
const onPress = useCallback((emoji: string) => {
const index = sortedReactions.indexOf(emoji);
setIndex(index);
}, [sortedReactions]);
const onScrollToIndexFailed = useCallback((info: ScrollIndexFailed) => {
const index = Math.min(info.highestMeasuredFrameIndex, info.index);
scrollToIndex(index);
}, []);
const renderItem = useCallback(({item}) => {
return (
<Item
count={reactionsByName.get(item)?.length || 0}
emojiName={item}
highlight={item === emojiSelected}
onPress={onPress}
/>
);
}, [onPress, emojiSelected, reactionsByName]);
useEffect(() => {
const t = setTimeout(() => {
listRef.current?.scrollToItem({
item: emojiSelected,
animated: false,
viewPosition: 1,
});
}, 100);
return () => clearTimeout(t);
}, []);
return (
<FlatList
bounces={false}
data={sortedReactions}
horizontal={true}
ref={listRef}
renderItem={renderItem}
style={style.container}
onScrollToIndexFailed={onScrollToIndexFailed}
overScrollMode='never'
/>
);
};
export default EmojiBar;

View file

@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {TouchableOpacity, View} from 'react-native';
import AnimatedNumbers from 'react-native-animated-numbers';
import Emoji from '@components/emoji';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type ReactionProps = {
count: number;
emojiName: string;
highlight: boolean;
onPress: (emojiName: string) => void;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
count: {
color: changeOpacity(theme.centerChannelColor, 0.56),
...typography('Body', 100, 'SemiBold'),
},
countContainer: {marginRight: 5},
countHighlight: {
color: theme.buttonBg,
},
customEmojiStyle: {color: '#000'},
emoji: {marginHorizontal: 5},
highlight: {
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
},
reaction: {
alignItems: 'center',
borderRadius: 4,
backgroundColor: theme.centerChannelBg,
flexDirection: 'row',
height: 32,
justifyContent: 'center',
marginRight: 12,
minWidth: 50,
},
};
});
const Reaction = ({count, emojiName, highlight, onPress}: ReactionProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const handlePress = useCallback(() => {
onPress(emojiName);
}, [onPress, emojiName]);
return (
<TouchableOpacity
onPress={handlePress}
style={[styles.reaction, (highlight && styles.highlight)]}
>
<View style={styles.emoji}>
<Emoji
emojiName={emojiName}
size={20}
textStyle={styles.customEmojiStyle}
testID={`reaction.emoji.${emojiName}`}
/>
</View>
<View style={styles.countContainer}>
<AnimatedNumbers
includeComma={false}
fontStyle={[styles.count, (highlight && styles.countHighlight)]}
animateToNumber={count}
animationDuration={450}
/>
</View>
</TouchableOpacity>
);
};
export default Reaction;

View file

@ -0,0 +1,30 @@
// 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 {observePost} from '@queries/servers/post';
import Reactions from './reactions';
import type {WithDatabaseArgs} from '@typings/database/database';
type EnhancedProps = WithDatabaseArgs & {
postId: string;
}
const enhanced = withObservables([], ({postId, database}: EnhancedProps) => {
const post = observePost(database, postId);
return {
reactions: post.pipe(
switchMap((p) => (p ? p.reactions.observe() : of$(undefined))),
),
};
});
export default withDatabase(enhanced(Reactions));

View file

@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Screens} from '@constants';
import BottomSheet from '@screens/bottom_sheet';
import {getEmojiFirstAlias} from '@utils/emoji/helpers';
import EmojiAliases from './emoji_aliases';
import EmojiBar from './emoji_bar';
import ReactorsList from './reactors_list';
import type ReactionModel from '@typings/database/models/servers/reaction';
type Props = {
initialEmoji: string;
reactions?: ReactionModel[];
}
const Reactions = ({initialEmoji, reactions}: Props) => {
const [sortedReactions, setSortedReactions] = useState(Array.from(new Set(reactions?.map((r) => getEmojiFirstAlias(r.emojiName)))));
const [index, setIndex] = useState(sortedReactions.indexOf(initialEmoji));
const reactionsByName = useMemo(() => {
return reactions?.reduce((acc, reaction) => {
const emojiAlias = getEmojiFirstAlias(reaction.emojiName);
if (acc.has(emojiAlias)) {
const rs = acc.get(emojiAlias);
// eslint-disable-next-line max-nested-callbacks
const present = rs!.findIndex((r) => r.userId === reaction.userId) > -1;
if (!present) {
rs!.push(reaction);
}
} else {
acc.set(emojiAlias, [reaction]);
}
return acc;
}, new Map<string, ReactionModel[]>());
}, [reactions]);
const renderContent = useCallback(() => {
const emojiAlias = sortedReactions[index];
if (!reactionsByName) {
return null;
}
return (
<>
<EmojiBar
emojiSelected={emojiAlias}
reactionsByName={reactionsByName}
setIndex={setIndex}
sortedReactions={sortedReactions}
/>
<EmojiAliases emoji={emojiAlias}/>
<ReactorsList
key={emojiAlias}
reactions={reactionsByName.get(emojiAlias)!}
/>
</>
);
}, [index, reactions, sortedReactions]);
useEffect(() => {
// This helps keep the reactions in the same position at all times until unmounted
const rs = reactions?.map((r) => getEmojiFirstAlias(r.emojiName));
const sorted = new Set([...sortedReactions]);
const added = rs?.filter((r) => !sorted.has(r));
added?.forEach(sorted.add, sorted);
const removed = [...sorted].filter((s) => !rs?.includes(s));
removed.forEach(sorted.delete, sorted);
setSortedReactions(Array.from(sorted));
}, [reactions]);
return (
<BottomSheet
renderContent={renderContent}
closeButtonId='close-post-reactions'
componentId={Screens.REACTIONS}
initialSnapIndex={1}
snapPoints={['90%', '50%', 10]}
/>
);
};
export default Reactions;

View file

@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {NativeScrollEvent, NativeSyntheticEvent, PanResponder} from 'react-native';
import {FlatList} from 'react-native-gesture-handler';
import {fetchUsersByIds} from '@actions/remote/user';
import {useServerUrl} from '@context/server';
import Reactor from './reactor';
import type ReactionModel from '@typings/database/models/servers/reaction';
type Props = {
reactions: ReactionModel[];
}
const ReactorsList = ({reactions}: Props) => {
const serverUrl = useServerUrl();
const [enabled, setEnabled] = useState(false);
const [direction, setDirection] = useState<'down' | 'up'>('down');
const listRef = useRef<FlatList>(null);
const prevOffset = useRef(0);
const panResponder = useRef(PanResponder.create({
onMoveShouldSetPanResponderCapture: (evt, g) => {
const dir = prevOffset.current < g.dy ? 'down' : 'up';
prevOffset.current = g.dy;
if (!enabled && dir === 'up') {
setEnabled(true);
}
setDirection(dir);
return false;
},
})).current;
const renderItem = useCallback(({item}) => (
<Reactor reaction={item}/>
), [reactions]);
const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (e.nativeEvent.contentOffset.y <= 0 && enabled && direction === 'down') {
setEnabled(false);
listRef.current?.scrollToOffset({animated: true, offset: 0});
}
}, [enabled, direction]);
useEffect(() => {
const userIds = reactions.map((r) => r.userId);
// Fetch any missing user
fetchUsersByIds(serverUrl, userIds);
}, []);
return (
<FlatList
data={reactions}
ref={listRef}
renderItem={renderItem}
onScroll={onScroll}
overScrollMode={'always'}
scrollEnabled={enabled}
scrollEventThrottle={60}
{...panResponder.panHandlers}
/>
);
};
export default ReactorsList;

View file

@ -0,0 +1,18 @@
// 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 {observeUser} from '@app/queries/servers/user';
import {WithDatabaseArgs} from '@typings/database/database';
import Reactor from './reactor';
import type ReactionModel from '@typings/database/models/servers/reaction';
const enhance = withObservables(['reaction'], ({database, reaction}: {reaction: ReactionModel} & WithDatabaseArgs) => ({
user: observeUser(database, reaction.userId),
}));
export default withDatabase(enhance(Reactor));

View file

@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet} from 'react-native';
import UserItem from '@components/user_item';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
user?: UserModel;
}
const style = StyleSheet.create({
container: {
marginBottom: 8,
},
});
const Reactor = ({user}: Props) => {
return (
<UserItem
containerStyle={style.container}
user={user}
/>
);
};
export default Reactor;

View file

@ -319,7 +319,6 @@ const Server = ({
<AnimatedSafeArea
key={'server_content'}
style={[styles.flex, transform]}
testID='select_server.screen'
>
<KeyboardAwareScrollView
bounces={false}

View file

@ -170,3 +170,5 @@ export const makeCallErrorResponse = (errMessage: string) => {
error: errMessage,
};
};
export const filterEmptyOptions = (option: AppSelectOption): boolean => Boolean(option.value && !option.value.match(/^[ \t]+$/));

View file

@ -193,6 +193,10 @@ export function doesMatchNamedEmoji(emojiName: string) {
return false;
}
export const getEmojiFirstAlias = (emoji: string) => {
return getEmojiByName(emoji, [])?.short_names?.[0] || emoji;
};
export function getEmojiByName(emojiName: string, customEmojis: CustomEmojiModel[]) {
if (EmojiIndicesByAlias.has(emojiName)) {
return Emojis[EmojiIndicesByAlias.get(emojiName)!];

View file

@ -62,6 +62,9 @@ export function buildQueryString(parameters: Dictionary<any>): string {
let query = '?';
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (parameters[key] == null) {
continue;
}
query += key + '=' + encodeURIComponent(parameters[key]);
if (i < keys.length - 1) {

View file

@ -6,27 +6,12 @@ import {StatusBar, StyleSheet} from 'react-native';
import tinyColor from 'tinycolor2';
import {Preferences} from '@constants';
import {MODAL_SCREENS_WITHOUT_BACK} from '@constants/screens';
import {appearanceControlledScreens, mergeNavigationOptions} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import type {Options} from 'react-native-navigation';
const MODAL_SCREENS_WITHOUT_BACK = [
'AddReaction',
'ChannelInfo',
'ClientUpgrade',
'CreateChannel',
'EditPost',
'ErrorTeamsList',
'MoreChannels',
'MoreDirectMessages',
'Permalink',
'SelectTeam',
'Settings',
'TermsOfService',
'UserProfile',
];
const rgbPattern = /^rgba?\((\d+),(\d+),(\d+)(?:,([\d.]+))?\)$/;
export function getComponents(inColor: string): {red: number; green: number; blue: number; alpha: number} {

View file

@ -123,13 +123,13 @@ export const getTimezone = (timezone: UserTimezone | null) => {
return timezone.manualTimezone;
};
export const getUserCustomStatus = (user: UserModel | UserProfile): UserCustomStatus | undefined => {
export const getUserCustomStatus = (user?: UserModel | UserProfile): UserCustomStatus | undefined => {
try {
if (typeof user.props?.customStatus === 'string') {
if (typeof user?.props?.customStatus === 'string') {
return JSON.parse(user.props.customStatus) as UserCustomStatus;
}
return user.props?.customStatus;
return user?.props?.customStatus;
} catch {
return undefined;
}
@ -192,8 +192,12 @@ export function confirmOutOfOfficeDisabled(intl: IntlShape, status: string, upda
);
}
export function isShared(user: UserProfile): boolean {
return Boolean(user.remote_id);
export function isBot(user: UserProfile | UserModel): boolean {
return 'is_bot' in user ? Boolean(user.is_bot) : Boolean(user.isBot);
}
export function isShared(user: UserProfile | UserModel): boolean {
return 'remote_id' in user ? Boolean(user.remote_id) : Boolean(user.props?.remote_id);
}
export function removeUserFromList(userId: string, originalList: UserProfile[]): UserProfile[] {

View file

@ -227,7 +227,7 @@ export const generateRandomUser = ({prefix = 'user', randomIdLength = 6} = {}) =
return {
email: `${prefix}${randomId}@sample.mattermost.com`,
username: `${prefix}${randomId}`,
password: 'passwd',
password: `P${randomId}!1234`,
first_name: `F${randomId}`,
last_name: `L${randomId}`,
nickname: `N${randomId}`,

View file

@ -10,10 +10,16 @@ class Alert {
return isAndroid() ? element(by.text(title)) : element(by.label(title)).atIndex(0);
};
removeServerTitle = (serverDisplayName: string) => {
const title = `Are you sure you want to remove ${serverDisplayName}?`;
return isAndroid() ? element(by.text(title)) : element(by.label(title)).atIndex(0);
};
// alert buttons
cancelButton = isAndroid() ? element(by.text('CANCEL')) : element(by.label('Cancel')).atIndex(1);
logoutButton = isAndroid() ? element(by.text('LOG OUT')) : element(by.label('Log out')).atIndex(1);
removeButton = isAndroid() ? element(by.text('REMOVE')) : element(by.label('Remove')).atIndex(1);
}
const alert = new Alert();

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {timeouts} from '@support/utils';
class EditServerScreen {
testID = {
editServerScreen: 'edit_server.screen',
closeButton: 'close-server-edit',
headerTitle: 'edit_server_header.title',
headerDescription: 'edit_server_header.description',
serverDisplayNameInput: 'edit_server_form.server_display_name.input',
serverDisplayNameInputError: 'edit_server_form.server_display_name.input.error',
displayHelp: 'edit_server_form.display_help',
saveButton: 'edit_server_form.save.button',
saveButtonDisabled: 'edit_server_form.save.button.disabled',
};
editServerScreen = element(by.id(this.testID.editServerScreen));
closeButton = element(by.id(this.testID.closeButton));
headerTitle = element(by.id(this.testID.headerTitle));
headerDescription = element(by.id(this.testID.headerDescription));
serverDisplayNameInput = element(by.id(this.testID.serverDisplayNameInput));
serverDisplayNameInputError = element(by.id(this.testID.serverDisplayNameInputError));
displayHelp = element(by.id(this.testID.displayHelp));
saveButton = element(by.id(this.testID.saveButton));
saveButtonDisabled = element(by.id(this.testID.saveButtonDisabled));
toBeVisible = async () => {
await waitFor(this.editServerScreen).toExist().withTimeout(timeouts.TEN_SEC);
await waitFor(this.serverDisplayNameInput).toBeVisible().withTimeout(timeouts.TEN_SEC);
return this.editServerScreen;
};
}
const editServerScreen = new EditServerScreen();
export default editServerScreen;

View file

@ -3,6 +3,7 @@
import AccountScreen from './account';
import ChannelListScreen from './channel_list';
import EditServerScreen from './edit_server';
import HomeScreen from './home';
import LoginScreen from './login';
import ServerScreen from './server';
@ -11,6 +12,7 @@ import ServerListScreen from './server_list';
export {
AccountScreen,
ChannelListScreen,
EditServerScreen,
HomeScreen,
LoginScreen,
ServerScreen,

View file

@ -6,6 +6,7 @@ import {timeouts} from '@support/utils';
class ServerScreen {
testID = {
serverScreen: 'server.screen',
closeButton: 'close-server',
headerTitleAddServer: 'server_header.title.add_server',
headerTitleConnectToServer: 'server_header.title.connect_to_server',
headerWelcome: 'server_header.welcome',
@ -20,6 +21,7 @@ class ServerScreen {
};
serverScreen = element(by.id(this.testID.serverScreen));
closeButton = element(by.id(this.testID.closeButton));
headerTitleAddServer = element(by.id(this.testID.headerTitleAddServer));
headerTitleConnectToServer = element(by.id(this.testID.headerTitleConnectToServer));
headerWelcome = element(by.id(this.testID.headerWelcome));

View file

@ -0,0 +1,255 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// *******************************************************************
// - [#] indicates a test step (e.g. # Go to a screen)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element testID when selecting an element. Create one if none.
// *******************************************************************
import {
User,
Setup,
} from '@support/server_api';
import {
serverOneUrl,
serverTwoUrl,
serverThreeUrl,
siteOneUrl,
siteTwoUrl,
siteThreeUrl,
} from '@support/test_config';
import {
Alert,
} from '@support/ui/component';
import {
ChannelListScreen,
EditServerScreen,
HomeScreen,
LoginScreen,
ServerScreen,
ServerListScreen,
} from '@support/ui/screen';
import {expect} from 'detox';
describe('Server Login - Server List', () => {
const serverOneDisplayName = 'Server 1';
const serverTwoDisplayName = 'Server 2';
const serverThreeDisplayName = 'Server 3';
let serverOneUser: any;
let serverTwoUser: any;
let serverThreeUser: any;
beforeAll(async () => {
// # Log in to the first server
({user: serverOneUser} = await Setup.apiInit(siteOneUrl));
await expect(ServerScreen.headerTitleConnectToServer).toBeVisible();
await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName);
await LoginScreen.login(serverOneUser);
});
afterAll(async () => {
// # Log out
await HomeScreen.logout();
});
it('MM-T4691_1 - should be able to add and log in to new servers', async () => {
// * Verify on channel list screen of the first server
await ChannelListScreen.toBeVisible();
await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverOneDisplayName);
// # Open server list screen
await ServerListScreen.open();
// * Verify first server is active
await ServerListScreen.toBeVisible();
await expect(ServerListScreen.getServerItemActive(serverOneDisplayName)).toBeVisible();
// # Add a second server and log in to the second server
await User.apiAdminLogin(siteTwoUrl);
({user: serverTwoUser} = await Setup.apiInit(siteTwoUrl));
await ServerListScreen.addServerButton.tap();
await expect(ServerScreen.headerTitleAddServer).toBeVisible();
await ServerScreen.connectToServer(serverTwoUrl, serverTwoDisplayName);
await LoginScreen.login(serverTwoUser);
// * Verify on channel list screen of the second server
await ChannelListScreen.toBeVisible();
await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverTwoDisplayName);
// # Open server list screen
await ServerListScreen.open();
// * Verify second server is active and first server is inactive
await ServerListScreen.toBeVisible();
await expect(ServerListScreen.getServerItemActive(serverTwoDisplayName)).toBeVisible();
await expect(ServerListScreen.getServerItemInactive(serverOneDisplayName)).toBeVisible();
// # Add a third server and log in to the third server
await User.apiAdminLogin(siteThreeUrl);
({user: serverThreeUser} = await Setup.apiInit(siteThreeUrl));
await ServerListScreen.addServerButton.tap();
await expect(ServerScreen.headerTitleAddServer).toBeVisible();
await ServerScreen.connectToServer(serverThreeUrl, serverThreeDisplayName);
await LoginScreen.login(serverThreeUser);
// * Verify on channel list screen of the third server
await ChannelListScreen.toBeVisible();
await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverThreeDisplayName);
// # Open server list screen
await ServerListScreen.open();
// * Verify third server is active, and first and second servers are inactive
await ServerListScreen.toBeVisible();
await expect(ServerListScreen.getServerItemActive(serverThreeDisplayName)).toBeVisible();
await expect(ServerListScreen.getServerItemInactive(serverOneDisplayName)).toBeVisible();
await expect(ServerListScreen.getServerItemInactive(serverTwoDisplayName)).toBeVisible();
// # Go back to first server
await ServerListScreen.getServerItemInactive(serverOneDisplayName).tap();
});
it('MM-T4691_2 - should be able to switch to another existing server', async () => {
// * Verify on channel list screen of the first server
await ChannelListScreen.toBeVisible();
await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverOneDisplayName);
// # Open server list screen and tap on third server
await ServerListScreen.open();
await ServerListScreen.getServerItemInactive(serverThreeDisplayName).tap();
// * Verify on channel list screen of the third server
await ChannelListScreen.toBeVisible();
await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverThreeDisplayName);
// # Open server list screen and go back to first server
await ServerListScreen.open();
await ServerListScreen.getServerItemInactive(serverOneDisplayName).tap();
});
it('MM-T4691_3 - should be able to edit server display name of active and inactive servers', async () => {
// * Verify on channel list screen of the first server
await ChannelListScreen.toBeVisible();
await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverOneDisplayName);
// # Open server list screen, swipe left on first server and tap on edit option
await ServerListScreen.open();
await ServerListScreen.getServerItemActive(serverOneDisplayName).swipe('left');
await ServerListScreen.getServerItemEditOption(serverOneDisplayName).tap();
// * Verify on edit server screen
await EditServerScreen.toBeVisible();
// # Enter the same first server display name
await EditServerScreen.serverDisplayNameInput.replaceText(serverOneDisplayName);
// * Verify save button is disabled
await expect(EditServerScreen.saveButtonDisabled).toBeVisible();
// # Enter a new first server display name
const newServerOneDisplayName = `${serverOneDisplayName} new`;
await EditServerScreen.serverDisplayNameInput.replaceText(newServerOneDisplayName);
// * Verify save button is enabled
await expect(EditServerScreen.saveButton).toBeVisible();
// # Tap on save button
await EditServerScreen.saveButton.tap();
// * Verify the new first server display name
await expect(ServerListScreen.getServerItemActive(newServerOneDisplayName)).toBeVisible();
// # Revert back to original first server display name and go back to first server
await ServerListScreen.getServerItemActive(newServerOneDisplayName).swipe('left');
await ServerListScreen.getServerItemEditOption(newServerOneDisplayName).tap();
await EditServerScreen.serverDisplayNameInput.replaceText(serverOneDisplayName);
await EditServerScreen.saveButton.tap();
await ServerListScreen.getServerItemActive(serverOneDisplayName).tap();
});
it('MM-T4691_4 - should be able to remove a server from the list', async () => {
// * Verify on channel list screen of the first server
await ChannelListScreen.toBeVisible();
await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverOneDisplayName);
// # Open server list screen, swipe left on first server and tap on remove option
await ServerListScreen.open();
await ServerListScreen.getServerItemActive(serverOneDisplayName).swipe('left');
await ServerListScreen.getServerItemRemoveOption(serverOneDisplayName).tap();
// * Verify remove server alert is displayed
await expect(Alert.removeServerTitle(serverOneDisplayName)).toBeVisible();
// # Tap on remove button and go back to server list screen
await Alert.removeButton.tap();
await ServerListScreen.open();
// * Verify first server is removed
await expect(ServerListScreen.getServerItemActive(serverOneDisplayName)).not.toExist();
await expect(ServerListScreen.getServerItemInactive(serverOneDisplayName)).not.toExist();
// # Add first server back to the list and log in to the first server
await ServerListScreen.addServerButton.tap();
await expect(ServerScreen.headerTitleAddServer).toBeVisible();
await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName);
await LoginScreen.login(serverOneUser);
});
it('MM-T4691_5 - should be able to log out a server from the list', async () => {
// * Verify on channel list screen of the first server
await ChannelListScreen.toBeVisible();
await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverOneDisplayName);
// # Open server list screen, swipe left on first server and tap on logout option
await ServerListScreen.open();
await ServerListScreen.getServerItemActive(serverOneDisplayName).swipe('left');
await ServerListScreen.getServerItemLogoutOption(serverOneDisplayName).tap();
// * Verify logout server alert is displayed
await expect(Alert.logoutTitle(serverOneDisplayName)).toBeVisible();
// # Tap on logout button and go back to server list screen
await Alert.logoutButton.tap();
await ServerListScreen.open();
// * Verify first server is logged out
await ServerListScreen.getServerItemInactive(serverOneDisplayName).swipe('left');
await expect(ServerListScreen.getServerItemLoginOption(serverOneDisplayName)).toBeVisible();
// # Log back in to first server
await ServerListScreen.getServerItemLoginOption(serverOneDisplayName).tap();
await expect(ServerScreen.headerTitleAddServer).toBeVisible();
await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName);
await LoginScreen.login(serverOneUser);
});
it('MM-T4691_6 - should not be able to add server for an already existing server', async () => {
// * Verify on channel list screen of the first server
await ChannelListScreen.toBeVisible();
await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverOneDisplayName);
// # Open server list screen, attempt to add a server already logged in and with inactive session
await ServerListScreen.open();
await ServerListScreen.addServerButton.tap();
await expect(ServerScreen.headerTitleAddServer).toBeVisible();
await ServerScreen.serverUrlInput.replaceText(serverTwoUrl);
await ServerScreen.serverDisplayNameInput.replaceText(serverTwoDisplayName);
await ServerScreen.connectButton.tap();
// * Verify same name server error
const sameNameServerError = 'You are using this name for another server.';
await expect(ServerScreen.serverDisplayNameInputError).toHaveText(sameNameServerError);
// # Attempt to add a server already logged in and with active session, with the same server display name
await ServerScreen.serverUrlInput.replaceText(serverOneUrl);
await ServerScreen.serverDisplayNameInput.replaceText(serverOneDisplayName);
await ServerScreen.connectButton.tap();
// * Verify same name server error
await expect(ServerScreen.serverDisplayNameInputError).toHaveText(sameNameServerError);
// # Close server screen to go back to first server
await ServerScreen.closeButton.tap();
});
});

View file

@ -7,28 +7,35 @@
// - Use element testID when selecting an element. Create one if none.
// *******************************************************************
import {Setup} from '@support/server_api';
import {
Setup,
User,
} from '@support/server_api';
import {
serverOneUrl,
siteOneUrl,
serverTwoUrl,
siteTwoUrl,
} from '@support/test_config';
import {
ChannelListScreen,
HomeScreen,
LoginScreen,
ServerListScreen,
ServerScreen,
} from '@support/ui/screen';
import {expect} from 'detox';
describe('Server Login', () => {
const serverOneDisplayName = 'Server 1';
const serverTwoDisplayName = 'Server 2';
afterAll(async () => {
// # Log out
await HomeScreen.logout(serverOneDisplayName);
});
it('MM-T4675 - should be able to connect to a server, log in, and show channel list screen', async () => {
it('MM-T4675_1 - should be able to connect to a server, log in, and show channel list screen', async () => {
// * Verify on server screen
await ServerScreen.toBeVisible();
@ -47,4 +54,28 @@ describe('Server Login', () => {
await expect(ChannelListScreen.headerTeamDisplayName).toHaveText(team.display_name);
await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverOneDisplayName);
});
it('MM-T4675_2 - should be able to add a new server and log in to the new server', async () => {
// # Open server list screen
await ServerListScreen.open();
// * Verify on server list screen
await ServerListScreen.toBeVisible();
// # Add a second server and log in to the second server
await User.apiAdminLogin(siteTwoUrl);
const {user} = await Setup.apiInit(siteTwoUrl);
await ServerListScreen.addServerButton.tap();
await expect(ServerScreen.headerTitleAddServer).toBeVisible();
await ServerScreen.connectToServer(serverTwoUrl, serverTwoDisplayName);
await LoginScreen.login(user);
// * Verify on channel list screen of the second server
await ChannelListScreen.toBeVisible();
await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverTwoDisplayName);
// # Go back to first server
await ServerListScreen.open();
await ServerListScreen.getServerItemInactive(serverOneDisplayName).tap();
});
});

109
package-lock.json generated
View file

@ -5,7 +5,6 @@
"requires": true,
"packages": {
"": {
"name": "mattermost-mobile",
"version": "2.0.0",
"hasInstallScript": true,
"license": "Apache 2.0",
@ -35,6 +34,7 @@
"@rudderstack/rudder-sdk-react-native": "1.2.1",
"@sentry/react-native": "3.2.13",
"@stream-io/flat-list-mvcp": "0.10.1",
"base-64": "1.0.0",
"commonmark": "github:mattermost/commonmark.js#90a62d97ed2dbd2d4711a5adda327128f5827983",
"commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#4e52e1725c0ef5b1e2ecfe9883220ec36c2eb67d",
"deep-equal": "2.0.5",
@ -109,6 +109,7 @@
"@babel/runtime": "7.17.2",
"@react-native-community/eslint-config": "3.0.1",
"@testing-library/react-native": "9.0.0",
"@types/base-64": "1.0.0",
"@types/commonmark": "0.27.5",
"@types/commonmark-react-renderer": "4.3.1",
"@types/deep-equal": "1.0.1",
@ -4260,7 +4261,7 @@
"integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==",
"dependencies": {
"@jest/types": "^26.6.2",
"ansi-regex": "^5.0.1",
"ansi-regex": "^5.0.0",
"ansi-styles": "^4.0.0",
"react-is": "^17.0.1"
},
@ -4302,7 +4303,7 @@
"chalk": "^4.1.2",
"lodash": "^4.17.15",
"mime": "^2.4.1",
"node-fetch": "^2.6.7",
"node-fetch": "^2.6.0",
"open": "^6.2.0",
"semver": "^6.3.0",
"shell-quote": "1.6.1"
@ -4604,7 +4605,7 @@
"integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==",
"dependencies": {
"@jest/types": "^26.6.2",
"ansi-regex": "^5.0.1",
"ansi-regex": "^5.0.0",
"ansi-styles": "^4.0.0",
"react-is": "^17.0.1"
},
@ -4881,6 +4882,7 @@
"version": "0.1.11",
"resolved": "https://registry.npmjs.org/@react-native-community/masked-view/-/masked-view-0.1.11.tgz",
"integrity": "sha512-rQfMIGSR/1r/SyN87+VD8xHHzDYeHaJq6elOSCAD+0iLagXkSI2pfA0LmSXP21uw5i3em7GkkRjfJ8wpqWXZNw==",
"deprecated": "Repository was moved to @react-native-masked-view/masked-view",
"peerDependencies": {
"react": ">=16.0",
"react-native": ">=0.57"
@ -5035,7 +5037,7 @@
"dependencies": {
"https-proxy-agent": "^5.0.0",
"mkdirp": "^0.5.5",
"node-fetch": "^2.6.7",
"node-fetch": "^2.6.0",
"npmlog": "^4.1.2",
"progress": "^2.0.3",
"proxy-from-env": "^1.1.0"
@ -5708,6 +5710,12 @@
"@babel/types": "^7.3.0"
}
},
"node_modules/@types/base-64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/base-64/-/base-64-1.0.0.tgz",
"integrity": "sha512-AvCJx/HrfYHmOQRFdVvgKMplXfzTUizmh0tz9GFTpDePWgCY4uoKll84zKlaRoeiYiCr7c9ZnqSTzkl0BUVD6g==",
"dev": true
},
"node_modules/@types/commonmark": {
"version": "0.27.5",
"resolved": "https://registry.npmjs.org/@types/commonmark/-/commonmark-0.27.5.tgz",
@ -7609,6 +7617,11 @@
"node": ">=0.10.0"
}
},
"node_modules/base-64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="
},
"node_modules/base/node_modules/define-property": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
@ -8631,7 +8644,7 @@
"node_modules/commonmark-react-renderer": {
"version": "4.3.5",
"resolved": "git+ssh://git@github.com/mattermost/commonmark-react-renderer.git#4e52e1725c0ef5b1e2ecfe9883220ec36c2eb67d",
"license": "MIT",
"integrity": "sha512-UwUgplz8kFSMCe9+Dg/BcV75lc7R/V6mvMYJq2p29i5aaIBd0252k9HeSGa2VtEPHfg2/trS9qC7iAxnO7r6ng==",
"dependencies": {
"lodash.assign": "^4.2.0",
"lodash.isplainobject": "^4.0.6",
@ -8820,7 +8833,7 @@
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz",
"integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=",
"deprecated": "core-js@<3.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Please, upgrade your dependencies to the actual version of core-js."
"deprecated": "core-js@<3.4 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Please, upgrade your dependencies to the actual version of core-js."
},
"node_modules/core-js-compat": {
"version": "3.20.3",
@ -13431,7 +13444,7 @@
"integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==",
"dev": true,
"dependencies": {
"node-fetch": "^2.6.7",
"node-fetch": "^2.6.1",
"whatwg-fetch": "^3.4.1"
}
},
@ -16547,7 +16560,7 @@
"integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==",
"dependencies": {
"@jest/types": "^26.6.2",
"ansi-regex": "^5.0.1",
"ansi-regex": "^5.0.0",
"ansi-styles": "^4.0.0",
"react-is": "^17.0.1"
},
@ -17586,9 +17599,8 @@
}
},
"node_modules/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz"
},
"node_modules/mississippi": {
"version": "3.0.0",
@ -17654,11 +17666,13 @@
"node_modules/mmjstool/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true
},
"node_modules/mmjstool/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"engines": {
"node": ">=8"
@ -17667,6 +17681,7 @@
"node_modules/mmjstool/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
@ -17680,6 +17695,7 @@
"node_modules/mmjstool/node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"engines": {
"node": ">=10"
@ -17688,6 +17704,7 @@
"node_modules/mmjstool/node_modules/yargs": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz",
"integrity": "sha512-WUANQeVgjLbNsEmGk20f+nlHgOqzRFpiGWVaBrYGYIGANIIu3lWjoyi0fNlFmJkvfhCZ6BXINe7/W2O2bV4iaA==",
"dev": true,
"dependencies": {
"cliui": "^7.0.2",
@ -17705,6 +17722,7 @@
"node_modules/mmjstool/node_modules/yargs-parser": {
"version": "21.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.0.tgz",
"integrity": "sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA==",
"dev": true,
"engines": {
"node": ">=12"
@ -18191,11 +18209,20 @@
"node_modules/node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
@ -19116,9 +19143,9 @@
}
},
"node_modules/plist": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.0.4.tgz",
"integrity": "sha512-ksrr8y9+nXOxQB2osVNqrgvX/XQPOXaU4BQMKjYq8PvaY1U18mo+fKgBSwzK+luSyinOuPae956lSVcBwxlAMg==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.0.5.tgz",
"integrity": "sha512-83vX4eYdQp3vP9SxuYgEM/G/pJQqLUz/V/xzPrzruLs7fz7jxGQ1msZ/mg1nwZxUSuOp4sb+/bEIbRrbzZRxDA==",
"dependencies": {
"base64-js": "^1.5.1",
"xmlbuilder": "^9.0.7"
@ -20220,7 +20247,7 @@
"integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==",
"dependencies": {
"@jest/types": "^26.6.2",
"ansi-regex": "^5.0.1",
"ansi-regex": "^5.0.0",
"ansi-styles": "^4.0.0",
"react-is": "^17.0.1"
},
@ -21653,6 +21680,7 @@
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz",
"integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==",
"deprecated": "See https://github.com/lydell/source-map-resolve#deprecated",
"dependencies": {
"atob": "^2.1.2",
"decode-uri-component": "^0.2.0",
@ -21681,7 +21709,8 @@
"node_modules/source-map-url": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz",
"integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw=="
"integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==",
"deprecated": "See https://github.com/lydell/source-map-url#deprecated"
},
"node_modules/split-on-first": {
"version": "1.1.0",
@ -23490,7 +23519,7 @@
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz",
"integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==",
"deprecated": "Chokidar 2 will break on node v14+. Upgrade to chokidar 3 with 15x less dependencies.",
"deprecated": "Chokidar 2 does not receive security updates since 2019. Upgrade to chokidar 3 with 15x fewer dependencies",
"dev": true,
"optional": true,
"peer": true,
@ -27210,7 +27239,7 @@
"integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==",
"requires": {
"@jest/types": "^26.6.2",
"ansi-regex": "^5.0.1",
"ansi-regex": "^5.0.0",
"ansi-styles": "^4.0.0",
"react-is": "^17.0.1"
}
@ -27645,7 +27674,7 @@
"integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==",
"requires": {
"@jest/types": "^26.6.2",
"ansi-regex": "^5.0.1",
"ansi-regex": "^5.0.0",
"ansi-styles": "^4.0.0",
"react-is": "^17.0.1"
}
@ -27683,7 +27712,7 @@
"chalk": "^4.1.2",
"lodash": "^4.17.15",
"mime": "^2.4.1",
"node-fetch": "^2.6.7",
"node-fetch": "^2.6.0",
"open": "^6.2.0",
"semver": "^6.3.0",
"shell-quote": "1.6.1"
@ -28042,7 +28071,7 @@
"requires": {
"https-proxy-agent": "^5.0.0",
"mkdirp": "^0.5.5",
"node-fetch": "^2.6.7",
"node-fetch": "^2.6.0",
"npmlog": "^4.1.2",
"progress": "^2.0.3",
"proxy-from-env": "^1.1.0"
@ -28525,6 +28554,12 @@
"@babel/types": "^7.3.0"
}
},
"@types/base-64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/base-64/-/base-64-1.0.0.tgz",
"integrity": "sha512-AvCJx/HrfYHmOQRFdVvgKMplXfzTUizmh0tz9GFTpDePWgCY4uoKll84zKlaRoeiYiCr7c9ZnqSTzkl0BUVD6g==",
"dev": true
},
"@types/commonmark": {
"version": "0.27.5",
"resolved": "https://registry.npmjs.org/@types/commonmark/-/commonmark-0.27.5.tgz",
@ -30051,6 +30086,11 @@
}
}
},
"base-64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -30878,6 +30918,7 @@
},
"commonmark-react-renderer": {
"version": "git+ssh://git@github.com/mattermost/commonmark-react-renderer.git#4e52e1725c0ef5b1e2ecfe9883220ec36c2eb67d",
"integrity": "sha512-UwUgplz8kFSMCe9+Dg/BcV75lc7R/V6mvMYJq2p29i5aaIBd0252k9HeSGa2VtEPHfg2/trS9qC7iAxnO7r6ng==",
"from": "commonmark-react-renderer@github:mattermost/commonmark-react-renderer#4e52e1725c0ef5b1e2ecfe9883220ec36c2eb67d",
"requires": {
"lodash.assign": "^4.2.0",
@ -34560,7 +34601,7 @@
"integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==",
"dev": true,
"requires": {
"node-fetch": "^2.6.7",
"node-fetch": "^2.6.1",
"whatwg-fetch": "^3.4.1"
}
},
@ -37302,7 +37343,7 @@
"integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==",
"requires": {
"@jest/types": "^26.6.2",
"ansi-regex": "^5.0.1",
"ansi-regex": "^5.0.0",
"ansi-styles": "^4.0.0",
"react-is": "^17.0.1"
}
@ -37863,9 +37904,8 @@
}
},
"minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz"
},
"mississippi": {
"version": "3.0.0",
@ -37919,16 +37959,19 @@
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true
},
"string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"requires": {
"emoji-regex": "^8.0.0",
@ -37939,11 +37982,13 @@
"y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true
},
"yargs": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz",
"integrity": "sha512-WUANQeVgjLbNsEmGk20f+nlHgOqzRFpiGWVaBrYGYIGANIIu3lWjoyi0fNlFmJkvfhCZ6BXINe7/W2O2bV4iaA==",
"dev": true,
"requires": {
"cliui": "^7.0.2",
@ -37958,6 +38003,7 @@
"yargs-parser": {
"version": "21.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.0.tgz",
"integrity": "sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA==",
"dev": true
}
}
@ -38337,6 +38383,7 @@
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"requires": {
"whatwg-url": "^5.0.0"
},
@ -39052,9 +39099,9 @@
}
},
"plist": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.0.4.tgz",
"integrity": "sha512-ksrr8y9+nXOxQB2osVNqrgvX/XQPOXaU4BQMKjYq8PvaY1U18mo+fKgBSwzK+luSyinOuPae956lSVcBwxlAMg==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.0.5.tgz",
"integrity": "sha512-83vX4eYdQp3vP9SxuYgEM/G/pJQqLUz/V/xzPrzruLs7fz7jxGQ1msZ/mg1nwZxUSuOp4sb+/bEIbRrbzZRxDA==",
"requires": {
"base64-js": "^1.5.1",
"xmlbuilder": "^9.0.7"
@ -39527,7 +39574,7 @@
"integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==",
"requires": {
"@jest/types": "^26.6.2",
"ansi-regex": "^5.0.1",
"ansi-regex": "^5.0.0",
"ansi-styles": "^4.0.0",
"react-is": "^17.0.1"
}

View file

@ -32,6 +32,7 @@
"@rudderstack/rudder-sdk-react-native": "1.2.1",
"@sentry/react-native": "3.2.13",
"@stream-io/flat-list-mvcp": "0.10.1",
"base-64": "1.0.0",
"commonmark": "github:mattermost/commonmark.js#90a62d97ed2dbd2d4711a5adda327128f5827983",
"commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#4e52e1725c0ef5b1e2ecfe9883220ec36c2eb67d",
"deep-equal": "2.0.5",
@ -106,6 +107,7 @@
"@babel/runtime": "7.17.2",
"@react-native-community/eslint-config": "3.0.1",
"@testing-library/react-native": "9.0.0",
"@types/base-64": "1.0.0",
"@types/commonmark": "0.27.5",
"@types/commonmark-react-renderer": "4.3.1",
"@types/deep-equal": "1.0.1",

10
types/api/apps.d.ts vendored
View file

@ -178,11 +178,11 @@ type AppField = {
};
type AutocompleteSuggestion = {
suggestion: string;
complete?: string;
description?: string;
hint?: string;
iconData?: string;
Suggestion: string;
Complete: string;
Description: string;
Hint: string;
IconData: string;
};
type AutocompleteSuggestionWithComplete = AutocompleteSuggestion & {

View file

@ -19,6 +19,7 @@ type Command = {
'display_name': string;
'description': string;
'url': string;
'autocomplete_icon_data'?: string;
};
type CommandArgs = {

View file

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