From b2408bd5d18f4a873f2ec66b4f964e450f8726d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Thu, 17 Mar 2022 18:07:04 +0100 Subject: [PATCH 01/23] Add command autocomplete --- app/actions/remote/apps.ts | 3 +- app/actions/remote/channel.ts | 16 + app/actions/remote/command.ts | 20 + app/client/rest/integrations.ts | 6 +- .../at_mention_item/at_mention_item.tsx | 17 +- .../channel_mention_item.tsx | 10 +- .../channel_mention_item/index.ts | 11 +- .../app_command_parser/app_command_parser.ts | 1438 +++++++++++++++++ .../app_command_parser/mentions.ts | 108 ++ .../app_slash_suggestion.tsx | 194 +++ .../app_slash_suggestion/index.ts | 25 + .../autocomplete/slash_suggestion/index.ts | 25 + .../slash_suggestion/slash_suggestion.tsx | 224 +++ .../slash_suggestion_item.tsx | 170 ++ app/constants/apps.ts | 7 + app/utils/apps.ts | 2 + app/utils/helpers.ts | 3 + app/utils/user/index.ts | 4 +- types/api/apps.d.ts | 2 +- types/api/integrations.d.ts | 1 + 20 files changed, 2265 insertions(+), 21 deletions(-) create mode 100644 app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts create mode 100644 app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts create mode 100644 app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx create mode 100644 app/components/autocomplete/slash_suggestion/app_slash_suggestion/index.ts create mode 100644 app/components/autocomplete/slash_suggestion/index.ts create mode 100644 app/components/autocomplete/slash_suggestion/slash_suggestion.tsx create mode 100644 app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx diff --git a/app/actions/remote/apps.ts b/app/actions/remote/apps.ts index c991ad29a..9168243c0 100644 --- a/app/actions/remote/apps.ts +++ b/app/actions/remote/apps.ts @@ -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(serverUrl: string, call: AppCallReq try { client = NetworkManager.getClient(serverUrl); } catch (error) { - return {error}; + return {error: makeCallErrorResponse((error as ClientError).message)}; } try { diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index e7f8e34b4..07d8a8473 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -913,3 +913,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}; + } +}; diff --git a/app/actions/remote/command.ts b/app/actions/remote/command.ts index 629bcf6aa..2610e2f36 100644 --- a/app/actions/remote/command.ts +++ b/app/actions/remote/command.ts @@ -200,3 +200,23 @@ 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); + 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); + return {suggestions: await client.getCommandAutocompleteSuggestionsList(term, teamId, channelId, rootId)}; + } catch (error) { + return {error: error as ClientErrorProps}; + } +}; diff --git a/app/client/rest/integrations.ts b/app/client/rest/integrations.ts index a3cce391b..020f596d3 100644 --- a/app/client/rest/integrations.ts +++ b/app/client/rest/integrations.ts @@ -7,7 +7,7 @@ import {PER_PAGE_DEFAULT} from './constants'; export interface ClientIntegrationsMix { getCommandsList: (teamId: string) => Promise; - getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, commandArgs?: CommandArgs) => Promise; + getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, channelId: string, rootId?: string) => Promise; getAutocompleteCommandsList: (teamId: string, page?: number, perPage?: number) => Promise; executeCommand: (command: string, commandArgs?: CommandArgs) => Promise; addCommand: (command: Command) => Promise; @@ -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'}, ); }; diff --git a/app/components/autocomplete/at_mention_item/at_mention_item.tsx b/app/components/autocomplete/at_mention_item/at_mention_item.tsx index b1227e778..b9307a24b 100644 --- a/app/components/autocomplete/at_mention_item/at_mention_item.tsx +++ b/app/components/autocomplete/at_mention_item/at_mention_item.tsx @@ -16,8 +16,10 @@ import {useTheme} from '@context/theme'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; import {getUserCustomStatus, isGuest, isShared} from '@utils/user'; +import type UserModel from '@typings/database/models/servers/user'; + type AtMentionItemProps = { - user: UserProfile; + user: UserProfile | UserModel; currentUserId: string; onPress: (username: string) => void; showFullName: boolean; @@ -25,12 +27,13 @@ type AtMentionItemProps = { isCustomStatusEnabled: boolean; } -const getName = (user: UserProfile, showFullName: boolean, isCurrentUser: boolean) => { +const getName = (user: UserProfile | UserModel, showFullName: boolean, isCurrentUser: boolean) => { let name = ''; const hasNickname = user.nickname.length > 0; - + const firstName = 'first_name' in user ? user.first_name : user.firstName; + const lastName = 'last_name' in user ? user.last_name : user.lastName; if (showFullName) { - name += `${user.first_name} ${user.last_name} `; + name += `${firstName} ${lastName} `; } if (hasNickname && !isCurrentUser) { @@ -100,7 +103,7 @@ const AtMentionItem = ({ const isCurrentUser = currentUserId === user.id; const name = getName(user, showFullName, isCurrentUser); const customStatus = getUserCustomStatus(user); - + const isBot = Boolean('is_bot' in user ? user.is_bot : user.isBot); return ( - {Boolean(user.is_bot) && ()} + {isBot && ()} {guest && ()} {Boolean(name.length) && ( - {isCustomStatusEnabled && !user.is_bot && customStatus && ( + {isCustomStatusEnabled && !isBot && customStatus && ( { 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} /> diff --git a/app/components/autocomplete/channel_mention_item/index.ts b/app/components/autocomplete/channel_mention_item/index.ts index 8231897d2..bf7aed392 100644 --- a/app/components/autocomplete/channel_mention_item/index.ts +++ b/app/components/autocomplete/channel_mention_item/index.ts @@ -12,22 +12,25 @@ import {MM_TABLES} from '@constants/database'; 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 {SERVER: {USER}} = MM_TABLES; const enhanced = withObservables([], ({database, channel}: WithDatabaseArgs & OwnProps) => { let user = of$(undefined); - if (channel.type === General.DM_CHANNEL) { - user = database.get(USER).findAndObserve(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 = database.get(USER).findAndObserve(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, diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts new file mode 100644 index 000000000..58d063dc2 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts @@ -0,0 +1,1438 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} from '@nozbe/watermelondb'; +import {IntlShape} from 'react-intl'; + +import {doAppCall} from '@actions/remote/apps'; +import {fetchChannelById, fetchChannelByName, searchChannels} from '@actions/remote/channel'; +import {fetchUsersByIds, fetchUsersByUsernames, searchUsers} from '@actions/remote/user'; +import {AppCallResponseTypes, AppCallTypes, AppFieldTypes, COMMAND_SUGGESTION_ERROR} from '@constants/apps'; +import DatabaseManager from '@database/manager'; +import {queryChannelById, queryChannelByName} from '@queries/servers/channel'; +import {queryCurrentTeamId} from '@queries/servers/system'; +import {queryUsersById, queryUsersByUsername} from '@queries/servers/user'; +import ChannelModel from '@typings/database/models/servers/channel'; +import UserModel from '@typings/database/models/servers/user'; +import {createCallRequest, filterEmptyOptions} from '@utils/apps'; + +import {getChannelSuggestions, getUserSuggestions, inTextMentionSuggestions} from './mentions'; +/* eslint-disable max-lines */ + +export enum ParseState { + Start = 'Start', + Command = 'Command', + EndCommand = 'EndCommand', + CommandSeparator = 'CommandSeparator', + StartParameter = 'StartParameter', + ParameterSeparator = 'ParameterSeparator', + Flag1 = 'Flag1', + Flag = 'Flag', + FlagValueSeparator = 'FlagValueSeparator', + StartValue = 'StartValue', + NonspaceValue = 'NonspaceValue', + QuotedValue = 'QuotedValue', + TickValue = 'TickValue', + EndValue = 'EndValue', + EndQuotedValue = 'EndQuotedValue', + EndTickedValue = 'EndTickedValue', + Error = 'Error', + Rest = 'Rest', +} + +interface FormsCache { + getForm: (location: string, binding: AppBinding) => Promise<{form?: AppForm; error?: string} | undefined>; +} + +interface Intl { + formatMessage(config: {id: string; defaultMessage: string}, values?: {[name: string]: any}): string; +} + +// TODO: Implemnet app bindings +const getCommandBindings = () => { + return []; +}; +const getRHSCommandBindings = () => { + return []; +}; +const getAppRHSCommandForm = (key: string) => {// eslint-disable-line @typescript-eslint/no-unused-vars + return undefined; +}; +const getAppCommandForm = (key: string) => {// eslint-disable-line @typescript-eslint/no-unused-vars + return undefined; +}; +const setAppRHSCommandForm = (key: string, form: AppForm) => {// eslint-disable-line @typescript-eslint/no-unused-vars + return undefined; +}; +const setAppCommandForm = (key: string, form: AppForm) => {// eslint-disable-line @typescript-eslint/no-unused-vars + return undefined; +}; + +// Common dependencies with Webapp. Due to the big change of removing redux, we may have to rethink how to deal with this. +const getExecuteSuggestion = (parsed: ParsedCommand) => null; // eslint-disable-line @typescript-eslint/no-unused-vars +export const EXECUTE_CURRENT_COMMAND_ITEM_ID = '_execute_current_command'; +export const parserErrorMessage = (intl: IntlShape, error: string, _command: string, _position: number): string => { // eslint-disable-line @typescript-eslint/no-unused-vars + return intl.formatMessage({ + id: 'apps.error.parser', + defaultMessage: 'Parsing error: {error}', + }, { + error, + }); +}; + +export type ExtendedAutocompleteSuggestion = AutocompleteSuggestion & { + type?: string; + item?: UserProfile | UserModel | Channel | ChannelModel; +} + +export class ParsedCommand { + state = ParseState.Start; + command: string; + i = 0; + incomplete = ''; + incompleteStart = 0; + binding: AppBinding | undefined; + form: AppForm | undefined; + formsCache: FormsCache; + field: AppField | undefined; + position = 0; + values: {[name: string]: string} = {}; + location = ''; + error = ''; + intl: Intl; + + constructor(command: string, formsCache: FormsCache, intl: IntlShape) { + this.command = command; + this.formsCache = formsCache || []; + this.intl = intl; + } + + private asError = (message: string): ParsedCommand => { + this.state = ParseState.Error; + this.error = message; + return this; + }; + + private findBinding = (b: AppBinding) => b.label.toLowerCase() === this.incomplete.toLowerCase(); + + // matchBinding finds the closest matching command binding. + public matchBinding = async (commandBindings: AppBinding[], autocompleteMode = false): Promise => { + if (commandBindings.length === 0) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.no_bindings', + defaultMessage: 'No command bindings.', + })); + } + let bindings = commandBindings; + + let done = false; + while (!done) { + let c = ''; + if (this.i < this.command.length) { + c = this.command[this.i]; + } + + switch (this.state) { + case ParseState.Start: { + if (c !== '/') { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.no_slash_start', + defaultMessage: 'Command must start with a `/`.', + })); + } + this.i++; + this.incomplete = ''; + this.incompleteStart = this.i; + this.state = ParseState.Command; + break; + } + + case ParseState.Command: { + switch (c) { + case '': { + if (autocompleteMode) { + // Finish in the Command state, 'incomplete' will have the query string + done = true; + } else { + this.state = ParseState.EndCommand; + } + break; + } + case ' ': + case '\t': { + this.state = ParseState.EndCommand; + break; + } + default: + this.incomplete += c; + this.i++; + break; + } + break; + } + + case ParseState.EndCommand: { + const binding = bindings.find(this.findBinding); + if (!binding) { + // gone as far as we could, this token doesn't match a sub-command. + // return the state from the last matching binding + done = true; + break; + } + this.binding = binding; + this.location += '/' + binding.label; + bindings = binding.bindings || []; + this.state = ParseState.CommandSeparator; + break; + } + + case ParseState.CommandSeparator: { + if (c === '') { + done = true; + } + + switch (c) { + case ' ': + case '\t': { + this.i++; + break; + } + default: { + this.incomplete = ''; + this.incompleteStart = this.i; + this.state = ParseState.Command; + break; + } + } + break; + } + + default: { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_state', + defaultMessage: 'Unreachable: Unexpected state in matchBinding: `{state}`.', + }, { + state: this.state, + })); + } + } + } + + if (!this.binding) { + if (autocompleteMode) { + return this; + } + + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.no_match', + defaultMessage: '`{command}`: No matching command found in this workspace.', + }, { + command: this.command, + })); + } + + if (!autocompleteMode && this.binding.bindings?.length) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.execute_non_leaf', + defaultMessage: 'You must select a subcommand.', + })); + } + + if (!this.binding.bindings?.length) { + this.form = this.binding?.form; + if (!this.form) { + const fetched = await this.formsCache.getForm(this.location, this.binding); + if (fetched?.error) { + return this.asError(fetched.error); + } + this.form = fetched?.form; + } + } + + return this; + }; + + // parseForm parses the rest of the command using the previously matched form. + public parseForm = (autocompleteMode = false): ParsedCommand => { + if (this.state === ParseState.Error || !this.form) { + return this; + } + + let fields: AppField[] = []; + if (this.form.fields) { + fields = this.form.fields; + } + + fields = fields.filter((f) => f.type !== AppFieldTypes.MARKDOWN && !f.readonly); + this.state = ParseState.StartParameter; + this.i = this.incompleteStart || 0; + let flagEqualsUsed = false; + let escaped = false; + + // eslint-disable-next-line no-constant-condition + while (true) { + let c = ''; + if (this.i < this.command.length) { + c = this.command[this.i]; + } + + switch (this.state) { + case ParseState.StartParameter: { + switch (c) { + case '': + return this; + case '-': { + // Named parameter (aka Flag). Flag1 consumes the optional second '-'. + this.state = ParseState.Flag1; + this.i++; + break; + } + case '—': { + // Em dash, introduced when two '-' are set in iOS. Will be considered as such. + this.state = ParseState.Flag; + this.i++; + this.incomplete = ''; + this.incompleteStart = this.i; + flagEqualsUsed = false; + break; + } + default: { + // Positional parameter. + this.position++; + // eslint-disable-next-line no-loop-func + let field = fields.find((f: AppField) => f.position === this.position); + if (!field) { + field = fields.find((f) => f.position === -1 && f.type === AppFieldTypes.TEXT); + if (!field || this.values[field.name]) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.no_argument_pos_x', + defaultMessage: 'Unable to identify argument.', + })); + } + this.incompleteStart = this.i; + this.incomplete = ''; + this.field = field; + this.state = ParseState.Rest; + break; + } + this.field = field; + this.state = ParseState.StartValue; + break; + } + } + break; + } + + case ParseState.Rest: { + if (!this.field) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_field_value', + defaultMessage: 'Field value is missing.', + })); + } + + if (autocompleteMode && c === '') { + return this; + } + + if (c === '') { + this.values[this.field.name] = this.incomplete; + return this; + } + + this.i++; + this.incomplete += c; + break; + } + + case ParseState.ParameterSeparator: { + this.incompleteStart = this.i; + switch (c) { + case '': + this.state = ParseState.StartParameter; + return this; + case ' ': + case '\t': { + this.i++; + break; + } + default: + this.state = ParseState.StartParameter; + break; + } + break; + } + + case ParseState.Flag1: { + // consume the optional second '-' + if (c === '-') { + this.i++; + } + this.state = ParseState.Flag; + this.incomplete = ''; + this.incompleteStart = this.i; + flagEqualsUsed = false; + break; + } + + case ParseState.Flag: { + if (c === '' && autocompleteMode) { + return this; + } + + switch (c) { + case '': + case ' ': + case '\t': + case '=': { + const field = fields.find((f) => f.label?.toLowerCase() === this.incomplete.toLowerCase()); + if (!field) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_flag', + defaultMessage: 'Command does not accept flag `{flagName}`.', + }, { + flagName: this.incomplete, + })); + } + this.state = ParseState.FlagValueSeparator; + this.field = field; + this.incomplete = ''; + break; + } + default: { + this.incomplete += c; + this.i++; + break; + } + } + break; + } + + case ParseState.FlagValueSeparator: { + this.incompleteStart = this.i; + switch (c) { + case '': { + if (autocompleteMode) { + return this; + } + this.state = ParseState.StartValue; + break; + } + case ' ': + case '\t': { + this.i++; + break; + } + case '=': { + if (flagEqualsUsed) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.multiple_equal', + defaultMessage: 'Multiple `=` signs are not allowed.', + })); + } + flagEqualsUsed = true; + this.i++; + break; + } + default: { + this.state = ParseState.StartValue; + } + } + break; + } + + case ParseState.StartValue: { + this.incomplete = ''; + this.incompleteStart = this.i; + switch (c) { + case '"': { + this.state = ParseState.QuotedValue; + this.i++; + break; + } + case '`': { + this.state = ParseState.TickValue; + this.i++; + break; + } + case ' ': + case '\t': + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_whitespace', + defaultMessage: 'Unreachable: Unexpected whitespace.', + })); + default: { + this.state = ParseState.NonspaceValue; + break; + } + } + break; + } + + case ParseState.NonspaceValue: { + switch (c) { + case '': + case ' ': + case '\t': { + this.state = ParseState.EndValue; + break; + } + default: { + this.incomplete += c; + this.i++; + break; + } + } + break; + } + + case ParseState.QuotedValue: { + switch (c) { + case '': { + if (!autocompleteMode) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_quote', + defaultMessage: 'Matching double quote expected before end of input.', + })); + } + return this; + } + case '"': { + if (this.incompleteStart === this.i - 1) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.empty_value', + defaultMessage: 'Empty values are not allowed.', + })); + } + this.i++; + this.state = ParseState.EndQuotedValue; + break; + } + case '\\': { + escaped = true; + this.i++; + break; + } + default: { + this.incomplete += c; + this.i++; + if (escaped) { + //TODO: handle \n, \t, other escaped chars + escaped = false; + } + break; + } + } + break; + } + + case ParseState.TickValue: { + switch (c) { + case '': { + if (!autocompleteMode) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_tick', + defaultMessage: 'Matching tick quote expected before end of input.', + })); + } + return this; + } + case '`': { + if (this.incompleteStart === this.i - 1) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.empty_value', + defaultMessage: 'Empty values are not allowed.', + })); + } + this.i++; + this.state = ParseState.EndTickedValue; + break; + } + default: { + this.incomplete += c; + this.i++; + break; + } + } + break; + } + + case ParseState.EndTickedValue: + case ParseState.EndQuotedValue: + case ParseState.EndValue: { + if (!this.field) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_field_value', + defaultMessage: 'Field value is missing.', + })); + } + + // special handling for optional BOOL values ('--boolflag true' + // vs '--boolflag next-positional' vs '--boolflag + // --next-flag...') + if (this.field.type === AppFieldTypes.BOOL && + ((autocompleteMode && !'true'.startsWith(this.incomplete) && !'false'.startsWith(this.incomplete)) || + (!autocompleteMode && this.incomplete !== 'true' && this.incomplete !== 'false'))) { + // reset back where the value started, and treat as a new parameter + this.i = this.incompleteStart; + this.values[this.field.name] = 'true'; + this.state = ParseState.StartParameter; + } else { + if (autocompleteMode && c === '') { + return this; + } + this.values[this.field.name] = this.incomplete; + this.incomplete = ''; + this.incompleteStart = this.i; + if (c === '') { + return this; + } + this.state = ParseState.ParameterSeparator; + } + break; + } + } + } + }; +} + +export class AppCommandParser { + private serverUrl: string; + private database: Database; + private channelID: string; + private teamID: string; + private rootPostID?: string; + private intl: IntlShape; + private theme: Theme; + + constructor(serverUrl: string, intl: IntlShape, channelID: string, teamID = '', rootPostID = '', theme: Theme) { + this.serverUrl = serverUrl; + this.database = DatabaseManager.serverDatabases[serverUrl]?.database; + this.channelID = channelID; + this.rootPostID = rootPostID; + this.teamID = teamID; + this.intl = intl; + this.theme = theme; + } + + // composeCallFromCommand creates the form submission call + public composeCallFromCommand = async (command: string): Promise<{call: AppCallRequest | null; errorMessage?: string}> => { + let parsed = new ParsedCommand(command, this, this.intl); + + const commandBindings = this.getCommandBindings(); + if (!commandBindings) { + return {call: null, + errorMessage: this.intl.formatMessage({ + id: 'apps.error.parser.no_bindings', + defaultMessage: 'No command bindings.', + })}; + } + + parsed = await parsed.matchBinding(commandBindings, false); + parsed = parsed.parseForm(false); + if (parsed.state === ParseState.Error) { + return {call: null, errorMessage: parserErrorMessage(this.intl, parsed.error, parsed.command, parsed.i)}; + } + + await this.addDefaultAndReadOnlyValues(parsed); + + const missing = this.getMissingFields(parsed); + if (missing.length > 0) { + const missingStr = missing.map((f) => f.label).join(', '); + return {call: null, + errorMessage: this.intl.formatMessage({ + id: 'apps.error.command.field_missing', + defaultMessage: 'Required fields missing: `{fieldName}`.', + }, { + fieldName: missingStr, + })}; + } + + return this.composeCallFromParsed(parsed); + }; + + private async addDefaultAndReadOnlyValues(parsed: ParsedCommand) { + if (!parsed.form?.fields) { + return; + } + + await Promise.all(parsed.form.fields.map(async (f) => { + if (!f.value) { + return; + } + + if (f.readonly || !(f.name in parsed.values)) { + switch (f.type) { + case AppFieldTypes.TEXT: + parsed.values[f.name] = f.value as string; + break; + case AppFieldTypes.BOOL: + parsed.values[f.name] = 'true'; + break; + case AppFieldTypes.USER: { + const userID = (f.value as AppSelectOption).value; + let user: UserModel | UserProfile = (await queryUsersById(this.database, [userID]))[0]; + if (!user) { + const res = await fetchUsersByIds(this.serverUrl, [userID]); + if ('error' in res) { + // Silently fail on default value + break; + } + user = res.users[0] || res.existingUsers[0]; + } + parsed.values[f.name] = user.username; + break; + } + case AppFieldTypes.CHANNEL: { + const channelID = (f.value as AppSelectOption).label; + let channel: ChannelModel | Channel | undefined = await queryChannelById(this.database, channelID); + if (!channel) { + const res = await fetchChannelById(this.serverUrl, channelID); + if ('error' in res) { + // Silently fail on default value + break; + } + channel = res.channel; + } + parsed.values[f.name] = channel.name; + break; + } + case AppFieldTypes.STATIC_SELECT: + case AppFieldTypes.DYNAMIC_SELECT: + parsed.values[f.name] = (f.value as AppSelectOption).value; + break; + case AppFieldTypes.MARKDOWN: + + // Do nothing + } + } + }) || []); + } + + // getSuggestionsBase is a synchronous function that returns results for base commands + public getSuggestionsBase = (pretext: string): AutocompleteSuggestion[] => { + const command = pretext.toLowerCase(); + const result: AutocompleteSuggestion[] = []; + + const bindings = this.getCommandBindings(); + + for (const binding of bindings) { + let base = binding.label; + if (!base) { + continue; + } + + if (base[0] !== '/') { + base = '/' + base; + } + + if (base.startsWith(command)) { + result.push({ + complete: binding.label, + suggestion: base, + description: binding.description || '', + hint: binding.hint || '', + iconData: binding.icon || '', + }); + } + } + + return result; + }; + + // getSuggestions returns suggestions for subcommands and/or form arguments + public getSuggestions = async (pretext: string): Promise => { + let parsed = new ParsedCommand(pretext, this, this.intl); + let suggestions: ExtendedAutocompleteSuggestion[] = []; + + const commandBindings = this.getCommandBindings(); + if (!commandBindings) { + return []; + } + + parsed = await parsed.matchBinding(commandBindings, true); + if (parsed.state === ParseState.Error) { + suggestions = this.getErrorSuggestion(parsed); + } + + if (parsed.state === ParseState.Command) { + suggestions = this.getCommandSuggestions(parsed); + } + + if (parsed.form || parsed.incomplete) { + parsed = parsed.parseForm(true); + if (parsed.state === ParseState.Error) { + suggestions = this.getErrorSuggestion(parsed); + } + const argSuggestions = await this.getParameterSuggestions(parsed); + suggestions = suggestions.concat(argSuggestions); + } + + // Add "Execute Current Command" suggestion + // TODO get full text from SuggestionBox + const executableStates: string[] = [ + ParseState.EndCommand, + ParseState.CommandSeparator, + ParseState.StartParameter, + ParseState.ParameterSeparator, + ParseState.EndValue, + ]; + const call = parsed.form?.call || parsed.binding?.call || parsed.binding?.form?.call; + const hasRequired = this.getMissingFields(parsed).length === 0; + const hasValue = (parsed.state !== ParseState.EndValue || (parsed.field && parsed.values[parsed.field.name] !== undefined)); + + if (executableStates.includes(parsed.state) && call && hasRequired && hasValue) { + const execute = getExecuteSuggestion(parsed); + if (execute) { + suggestions = [execute, ...suggestions]; + } + } else if (suggestions.length === 0 && (parsed.field?.type !== AppFieldTypes.USER && parsed.field?.type !== AppFieldTypes.CHANNEL)) { + suggestions = this.getNoMatchingSuggestion(); + } + return suggestions.map((suggestion) => this.decorateSuggestionComplete(parsed, suggestion)); + }; + + getNoMatchingSuggestion = (): AutocompleteSuggestion[] => { + return [{ + complete: '', + suggestion: '', + hint: this.intl.formatMessage({ + id: 'apps.suggestion.no_suggestion', + defaultMessage: 'No matching suggestions.', + }), + iconData: COMMAND_SUGGESTION_ERROR, + description: '', + }]; + }; + + getErrorSuggestion = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + return [{ + complete: '', + suggestion: '', + hint: this.intl.formatMessage({ + id: 'apps.suggestion.errors.parser_error', + defaultMessage: 'Parsing error', + }), + iconData: COMMAND_SUGGESTION_ERROR, + description: parsed.error, + }]; + }; + + // composeCallFromParsed creates the form submission call + private composeCallFromParsed = async (parsed: ParsedCommand): Promise<{call: AppCallRequest | null; errorMessage?: string}> => { + if (!parsed.binding) { + return {call: null, + errorMessage: this.intl.formatMessage({ + id: 'apps.error.parser.missing_binding', + defaultMessage: 'Missing command bindings.', + })}; + } + + const call = parsed.form?.call || parsed.binding.call; + if (!call) { + return {call: null, + errorMessage: this.intl.formatMessage({ + id: 'apps.error.parser.missing_call', + defaultMessage: 'Missing binding call.', + })}; + } + + const values: AppCallValues = parsed.values; + const {errorMessage} = await this.expandOptions(parsed, values); + + if (errorMessage) { + return {call: null, errorMessage}; + } + + const context = await this.getAppContext(parsed.binding); + return {call: createCallRequest(call, context, {}, values, parsed.command)}; + }; + + private expandOptions = async (parsed: ParsedCommand, values: AppCallValues): Promise<{errorMessage?: string}> => { + if (!parsed.form?.fields) { + return {}; + } + + const errors: {[key: string]: string} = {}; + await Promise.all(parsed.form.fields.map(async (f) => { + if (!values[f.name]) { + return; + } + switch (f.type) { + case AppFieldTypes.DYNAMIC_SELECT: + values[f.name] = {label: '', value: values[f.name]}; + break; + case AppFieldTypes.STATIC_SELECT: { + const option = f.options?.find((o) => (o.value === values[f.name])); + if (!option) { + errors[f.name] = this.intl.formatMessage({ + id: 'apps.error.command.unknown_option', + defaultMessage: 'Unknown option for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: values[f.name], + }); + return; + } + values[f.name] = option; + break; + } + case AppFieldTypes.USER: { + let userName = values[f.name] as string; + if (userName[0] === '@') { + userName = userName.substr(1); + } + let user: UserModel | UserProfile | undefined = (await queryUsersByUsername(this.database, [userName]))[0]; + if (!user) { + const res = await fetchUsersByUsernames(this.serverUrl, [userName]); + if ('error' in res) { + errors[f.name] = this.intl.formatMessage({ + id: 'apps.error.command.unknown_user', + defaultMessage: 'Unknown user for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: values[f.name], + }); + return; + } + user = res.users[0]; + } + values[f.name] = {label: user.username, value: user.id}; + break; + } + case AppFieldTypes.CHANNEL: { + let channelName = values[f.name] as string; + if (channelName[0] === '~') { + channelName = channelName.substr(1); + } + let channel: ChannelModel | Channel | undefined = await queryChannelByName(this.database, channelName); + if (!channel) { + const res = await fetchChannelByName(this.serverUrl, this.teamID, channelName); + if ('error' in res) { + errors[f.name] = this.intl.formatMessage({ + id: 'apps.error.command.unknown_channel', + defaultMessage: 'Unknown channel for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: values[f.name], + }); + return; + } + channel = res.channel; + } + const label = 'display_name' in channel ? channel.display_name : channel.displayName; + values[f.name] = {label, value: channel?.id}; + break; + } + } + })); + + if (Object.keys(errors).length === 0) { + return {}; + } + + let errorMessage = ''; + Object.keys(errors).forEach((v) => { + errorMessage = errorMessage + errors[v] + '\n'; + }); + return {errorMessage}; + }; + + // decorateSuggestionComplete applies the necessary modifications for a suggestion to be processed + private decorateSuggestionComplete = (parsed: ParsedCommand, choice: AutocompleteSuggestion): AutocompleteSuggestion => { + if (choice.complete && choice.complete.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID)) { + return choice as AutocompleteSuggestion; + } + + let goBackSpace = 0; + if (choice.complete === '') { + goBackSpace = 1; + } + let complete = parsed.command.substring(0, parsed.incompleteStart - goBackSpace); + complete += choice.complete === undefined ? choice.suggestion : choice.complete; + choice.hint = choice.hint || ''; + complete = complete.substring(1); + + return { + ...choice, + complete, + }; + }; + + // getCommandBindings returns the commands in the redux store. + // They are grouped by app id since each app has one base command + private getCommandBindings = (): AppBinding[] => { + if (this.rootPostID) { + return getRHSCommandBindings(); + } + return getCommandBindings(); + }; + + // getChannel gets the channel in which the user is typing the command + private getChannel = async () => { + return queryChannelById(this.database, this.channelID); + }; + + public setChannelContext = (channelID: string, teamID = '', rootPostID?: string) => { + this.channelID = channelID; + this.rootPostID = rootPostID; + this.teamID = teamID; + }; + + // isAppCommand determines if subcommand/form suggestions need to be returned. + // When this returns true, the caller knows that the parser should handle all suggestions for the current command string. + // When it returns false, the caller should call getSuggestionsBase() to check if there are any base commands that match the command string. + public isAppCommand = (pretext: string): boolean => { + const command = pretext.toLowerCase(); + for (const binding of this.getCommandBindings()) { + let base = binding.label; + if (!base) { + continue; + } + + if (base[0] !== '/') { + base = '/' + base; + } + + if (command.startsWith(base + ' ')) { + return true; + } + } + return false; + }; + + // getAppContext collects post/channel/team info for performing calls + private getAppContext = async (binding: AppBinding): Promise => { + const context: AppContext = { + app_id: binding.app_id, + location: binding.location, + root_id: this.rootPostID, + }; + + const channel = await this.getChannel(); + if (!channel) { + return context; + } + + context.channel_id = channel.id; + context.team_id = channel.teamId || await queryCurrentTeamId(this.database); + + return context; + }; + + // fetchForm unconditionaly retrieves the form for the given binding (subcommand) + private fetchForm = async (binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => { + if (!binding.call) { + return {error: this.intl.formatMessage({ + id: 'apps.error.parser.missing_call', + defaultMessage: 'Missing binding call.', + })}; + } + + const payload = createCallRequest( + binding.call, + await this.getAppContext(binding), + ); + + const res = await doAppCall(this.serverUrl, payload, AppCallTypes.FORM, this.intl, this.theme); + if (res.error) { + const errorResponse = res.error; + return {error: errorResponse.error || this.intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error.', + })}; + } + + const callResponse = res.data!; + switch (callResponse.type) { + case AppCallResponseTypes.FORM: + break; + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.OK: + return {error: this.intl.formatMessage({ + id: 'apps.error.responses.unexpected_type', + defaultMessage: 'App response type was not expected. Response type: {type}', + }, { + type: callResponse.type, + })}; + default: + return {error: this.intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResponse.type, + })}; + } + + return {form: callResponse.form}; + }; + + public getForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => { + const rootID = this.rootPostID || ''; + const key = `${this.channelID}-${rootID}-${location}`; + const form = this.rootPostID ? getAppRHSCommandForm(key) : getAppCommandForm(key); + if (form) { + return {form}; + } + + const fetched = await this.fetchForm(binding); + if (fetched?.form) { + if (this.rootPostID) { + setAppRHSCommandForm(key, fetched.form); + } else { + setAppCommandForm(key, fetched.form); + } + } + return fetched; + }; + + // getSuggestionsForSubCommands returns suggestions for a subcommand's name + private getCommandSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + if (!parsed.binding?.bindings?.length) { + return []; + } + const bindings = parsed.binding.bindings; + const result: AutocompleteSuggestion[] = []; + + bindings.forEach((b) => { + if (b.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())) { + result.push({ + complete: b.label, + suggestion: b.label, + description: b.description || '', + hint: b.hint || '', + iconData: b.icon || '', + }); + } + }); + + return result; + }; + + // getParameterSuggestions computes suggestions for positional argument values, flag names, and flag argument values + private getParameterSuggestions = async (parsed: ParsedCommand): Promise => { + switch (parsed.state) { + case ParseState.StartParameter: { + // see if there's a matching positional field + const positional = parsed.form?.fields?.find((f: AppField) => f.position === parsed.position + 1); + if (positional) { + parsed.field = positional; + return this.getValueSuggestions(parsed); + } + return this.getFlagNameSuggestions(parsed); + } + + case ParseState.Flag: + return this.getFlagNameSuggestions(parsed); + + case ParseState.EndValue: + case ParseState.FlagValueSeparator: + case ParseState.NonspaceValue: + return this.getValueSuggestions(parsed); + case ParseState.EndQuotedValue: + case ParseState.QuotedValue: + return this.getValueSuggestions(parsed, '"'); + case ParseState.EndTickedValue: + case ParseState.TickValue: + return this.getValueSuggestions(parsed, '`'); + case ParseState.Rest: { + const execute = getExecuteSuggestion(parsed); + const value = await this.getValueSuggestions(parsed); + if (execute) { + return [execute, ...value]; + } + return value; + } + } + return []; + }; + + // getMissingFields collects the required fields that were not supplied in a submission + private getMissingFields = (parsed: ParsedCommand): AppField[] => { + const form = parsed.form; + if (!form) { + return []; + } + + const missing: AppField[] = []; + + const values = parsed.values || []; + const fields = form.fields || []; + for (const field of fields) { + if (field.is_required && !values[field.name]) { + missing.push(field); + } + } + + return missing; + }; + + // getFlagNameSuggestions returns suggestions for flag names + private getFlagNameSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + if (!parsed.form || !parsed.form.fields || !parsed.form.fields.length) { + return []; + } + + // There have been 0 to 2 dashes in the command prior to this call, adjust. + const prevCharIndex = parsed.incompleteStart - 1; + let prefix = '--'; + for (let i = prevCharIndex; i > 0 && i >= parsed.incompleteStart - 2 && parsed.command[i] === '-'; i--) { + prefix = prefix.substring(1); + } + if (prevCharIndex > 0 && parsed.command[prevCharIndex] === '—') { + prefix = ''; + } + + const applicable = parsed.form.fields.filter((field) => field.label && field.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase()) && !parsed.values[field.name]); + if (applicable) { + return applicable.map((f) => { + return { + complete: prefix + (f.label || f.name), + suggestion: '--' + (f.label || f.name), + description: f.description || '', + hint: f.hint || '', + iconData: parsed.binding?.icon || '', + }; + }); + } + + return []; + }; + + // getSuggestionsForField gets suggestions for a positional or flag field value + private getValueSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => { + if (!parsed || !parsed.field) { + return []; + } + const f = parsed.field; + + switch (f.type) { + case AppFieldTypes.USER: + return this.getUserFieldSuggestions(parsed); + case AppFieldTypes.CHANNEL: + return this.getChannelFieldSuggestions(parsed); + case AppFieldTypes.BOOL: + return this.getBooleanSuggestions(parsed); + case AppFieldTypes.DYNAMIC_SELECT: + return this.getDynamicSelectSuggestions(parsed, delimiter); + case AppFieldTypes.STATIC_SELECT: + return this.getStaticSelectSuggestions(parsed, delimiter); + } + + const mentionSuggestions = await inTextMentionSuggestions(this.serverUrl, parsed.incomplete, this.channelID, this.teamID, delimiter); + if (mentionSuggestions) { + return mentionSuggestions; + } + + // Handle text values + let complete = parsed.incomplete; + if (complete && delimiter) { + complete = delimiter + complete + delimiter; + } + + const fieldName = parsed.field.modal_label || parsed.field.label || parsed.field.name; + return [{ + complete, + suggestion: `${fieldName}: ${delimiter || '"'}${parsed.incomplete}${delimiter || '"'}`, + description: f.description || '', + hint: '', + iconData: parsed.binding?.icon || '', + }]; + }; + + // getStaticSelectSuggestions returns suggestions specified in the field's options property + private getStaticSelectSuggestions = (parsed: ParsedCommand, delimiter?: string): AutocompleteSuggestion[] => { + const f = parsed.field as AutocompleteStaticSelect; + const opts = f.options?.filter((opt) => opt.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())); + if (!opts?.length) { + return [{ + complete: '', + suggestion: '', + hint: this.intl.formatMessage({ + id: 'apps.suggestion.no_static', + defaultMessage: 'No matching options.', + }), + description: '', + iconData: COMMAND_SUGGESTION_ERROR, + }]; + } + return opts.map((opt) => { + let complete = opt.value; + if (delimiter) { + complete = delimiter + complete + delimiter; + } else if (isMultiword(opt.value)) { + complete = '`' + complete + '`'; + } + return { + complete, + suggestion: opt.label, + hint: f.hint || '', + description: f.description || '', + iconData: opt.icon_data || parsed.binding?.icon || '', + }; + }); + }; + + // getDynamicSelectSuggestions fetches and returns suggestions from the server + private getDynamicSelectSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => { + const f = parsed.field; + if (!f) { + // Should never happen + return this.makeDynamicSelectSuggestionError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_error', + defaultMessage: 'Unexpected error.', + })); + } + + const {call, errorMessage} = await this.composeCallFromParsed(parsed); + if (!call) { + return this.makeDynamicSelectSuggestionError(this.intl.formatMessage({ + id: 'apps.error.lookup.error_preparing_request', + defaultMessage: 'Error preparing lookup request: {errorMessage}', + }, { + errorMessage, + })); + } + call.selected_field = f.name; + call.query = parsed.incomplete; + + const res = await doAppCall(this.serverUrl, call, AppCallTypes.LOOKUP, this.intl, this.theme); + + if (res.error) { + const errorResponse = res.error; + return this.makeDynamicSelectSuggestionError(errorResponse.error || this.intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error.', + })); + } + + const callResponse = res.data!; + switch (callResponse.type) { + case AppCallResponseTypes.OK: + break; + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.FORM: + return this.makeDynamicSelectSuggestionError(this.intl.formatMessage({ + id: 'apps.error.responses.unexpected_type', + defaultMessage: 'App response type was not expected. Response type: {type}', + }, { + type: callResponse.type, + })); + default: + return this.makeDynamicSelectSuggestionError(this.intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResponse.type, + })); + } + + let items = callResponse?.data?.items; + items = items?.filter(filterEmptyOptions); + if (!items?.length) { + return [{ + complete: '', + suggestion: '', + hint: this.intl.formatMessage({ + id: 'apps.suggestion.no_static', + defaultMessage: 'No matching options.', + }), + iconData: '', + description: this.intl.formatMessage({ + id: 'apps.suggestion.no_dynamic', + defaultMessage: 'No data was returned for dynamic suggestions', + }), + }]; + } + + return items.map((s): AutocompleteSuggestion => { + let complete = s.value; + if (delimiter) { + complete = delimiter + complete + delimiter; + } else if (isMultiword(s.value)) { + complete = '`' + complete + '`'; + } + return ({ + complete, + description: s.label || s.value, + suggestion: s.value, + hint: '', + iconData: s.icon_data || parsed.binding?.icon || '', + }); + }); + }; + + private makeDynamicSelectSuggestionError = (message: string): AutocompleteSuggestion[] => { + const errMsg = this.intl.formatMessage({ + id: 'apps.error', + defaultMessage: 'Error: {error}', + }, { + error: message, + }); + return [{ + complete: '', + suggestion: '', + hint: this.intl.formatMessage({ + id: 'apps.suggestion.dynamic.error', + defaultMessage: 'Dynamic select error', + }), + iconData: COMMAND_SUGGESTION_ERROR, + description: errMsg, + }]; + }; + + private getUserFieldSuggestions = async (parsed: ParsedCommand): Promise => { + let input = parsed.incomplete.trim(); + if (input[0] === '@') { + input = input.substring(1); + } + const res = await searchUsers(this.serverUrl, input, this.channelID); + return getUserSuggestions(res.users); + }; + + private getChannelFieldSuggestions = async (parsed: ParsedCommand): Promise => { + let input = parsed.incomplete.trim(); + if (input[0] === '~') { + input = input.substring(1); + } + const res = await searchChannels(this.serverUrl, input); + return getChannelSuggestions(res.channels); + }; + + // getBooleanSuggestions returns true/false suggestions + private getBooleanSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + const suggestions: AutocompleteSuggestion[] = []; + + if ('true'.startsWith(parsed.incomplete)) { + suggestions.push({ + complete: 'true', + suggestion: 'true', + description: parsed.field?.description || '', + hint: parsed.field?.hint || '', + iconData: parsed.binding?.icon || '', + }); + } + if ('false'.startsWith(parsed.incomplete)) { + suggestions.push({ + complete: 'false', + suggestion: 'false', + description: parsed.field?.description || '', + hint: parsed.field?.hint || '', + iconData: parsed.binding?.icon || '', + }); + } + return suggestions; + }; +} + +function isMultiword(value: string) { + if (value.indexOf(' ') !== -1) { + return true; + } + + if (value.indexOf('\t') !== -1) { + return true; + } + + return false; +} diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts new file mode 100644 index 000000000..a11f71082 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts @@ -0,0 +1,108 @@ +// 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 { + 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 { + 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 { + const notFoundSuggestion = [{ + complete: '', + suggestion: '', + description: 'No channel found', + hint: '', + iconData: '', + }]; + if (!channels) { + return notFoundSuggestion; + } + 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, + }; +} diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx new file mode 100644 index 000000000..e60189d7d --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx @@ -0,0 +1,194 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, 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; + onChangeText: (text: string) => void; + onResultCountChange: (count: number) => void; + value: string; + nestedScrollEnabled?: boolean; + rootId?: string; + channelId: string; + appsEnabled: 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 AppSlashSuggestion = ({ + channelId, + currentTeamId, + rootId, + value = '', + appsEnabled, + maxListHeight, + nestedScrollEnabled, + onChangeText, + onResultCountChange, +}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const serverUrl = useServerUrl(); + const appCommandParser = useRef(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme)); + const [dataSource, setDataSource] = useState([]); + const active = appsEnabled && Boolean(dataSource.length); + const style = getStyleFromTheme(theme); + + const updateSuggestions = (matches: ExtendedAutocompleteSuggestion[]) => { + setDataSource(matches); + onResultCountChange(matches.length); + }; + + const completeSuggestion = (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} `; + } + + onChangeText(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(() => { + onChangeText(completedDraft.replace(`//${command} `, `/${command} `)); + }); + } + }; + + const completeUserSuggestion = (base: string): (username: string) => void => { + return () => { + completeSuggestion(base); + }; + }; + + const completeChannelMention = (base: string): (channelName: string) => void => { + return () => { + completeSuggestion(base); + }; + }; + + const renderItem = ({item}: {item: ExtendedAutocompleteSuggestion}) => { + switch (item.type) { + case COMMAND_SUGGESTION_USER: + if (!item.item) { + return null; + } + return ( + + ); + case COMMAND_SUGGESTION_CHANNEL: + if (!item.item) { + return null; + } + return ( + + ); + default: + return ( + + ); + } + }; + + const isAppCommand = (pretext: string, channelID: string, teamID = '', rootID?: string) => { + appCommandParser.current.setChannelContext(channelID, teamID, rootID); + return appCommandParser.current.isAppCommand(pretext); + }; + + const fetchAndShowAppCommandSuggestions = async (pretext: string, channelID: string, teamID = '', rootID?: string) => { + appCommandParser.current.setChannelContext(channelID, teamID, rootID); + const suggestions = await appCommandParser.current.getSuggestions(pretext); + updateSuggestions(suggestions); + }; + + useEffect(() => { + if (value[0] !== '/') { + setDataSource([]); + onResultCountChange(0); + return; + } + + if (value.indexOf(' ') === -1) { + setDataSource([]); + return; + } + + if (!isAppCommand(value, channelId, currentTeamId, rootId)) { + setDataSource([]); + return; + } + fetchAndShowAppCommandSuggestions(value, channelId, currentTeamId, rootId); + }, [value]); + + if (!active) { + // If we are not in an active state return null so nothing is rendered + // other components are not blocked. + return null; + } + + return ( + + ); +}; +export default AppSlashSuggestion; diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/index.ts b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/index.ts new file mode 100644 index 000000000..7c9b50101 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/index.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; + +import AppSlashSuggestion from './app_slash_suggestion'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type SystemModel from '@typings/database/models/servers/system'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ + currentTeamId: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe( + switchMap((v) => of$(v.value)), + ), + isAppsEnabled: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((cfg) => of$(cfg.value.FeatureFlagAppsEnabled === 'true')), + ), +})); + +export default withDatabase(enhanced(AppSlashSuggestion)); diff --git a/app/components/autocomplete/slash_suggestion/index.ts b/app/components/autocomplete/slash_suggestion/index.ts new file mode 100644 index 000000000..f5b965989 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/index.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; + +import SlashSuggestion from './slash_suggestion'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type SystemModel from '@typings/database/models/servers/system'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ + currentTeamId: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe( + switchMap((v) => of$(v.value)), + ), + isAppsEnabled: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((cfg) => of$(cfg.value.FeatureFlagAppsEnabled === 'true')), + ), +})); + +export default withDatabase(enhanced(SlashSuggestion)); diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx new file mode 100644 index 000000000..e54a3296a --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx @@ -0,0 +1,224 @@ +// 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 {fetchCommands, fetchSuggestions} from '@actions/remote/command'; +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((item) => { + return { + complete: item.trigger, + suggestion: '/' + item.trigger, + hint: item.auto_complete_hint, + description: item.auto_complete_desc, + iconData: item.icon_url || item.autocomplete_icon_data || '', + }; + }); +}; + +const keyExtractor = (item: Command & AutocompleteSuggestion): string => item.id || item.suggestion || ''; + +type Props = { + currentTeamId: string; + commands: Command[]; + maxListHeight?: number; + onChangeText: (text: string) => void; + onResultCountChange: (count: number) => void; + value: string; + nestedScrollEnabled?: boolean; + rootId?: string; + channelId: string; + appsEnabled: boolean; +}; + +const emptyCommandList: Command[] = []; +const emptySuggestionList: AutocompleteSuggestion[] = []; + +const SlashSuggestion = ({ + channelId, + currentTeamId, + rootId, + onResultCountChange, + appsEnabled, + maxListHeight, + nestedScrollEnabled, + onChangeText, + value = '', +}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const style = getStyleFromTheme(theme); + const serverUrl = useServerUrl(); + const appCommandParser = useRef(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme)); + + const [dataSource, setDataSource] = useState(emptySuggestionList); + const [commands, setCommands] = useState(); + + const active = Boolean(dataSource.length); + + const updateSuggestions = useCallback((matches: AutocompleteSuggestion[]) => { + setDataSource(matches); + onResultCountChange(matches.length); + }, [onResultCountChange]); + + 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 (res.error) { + updateSuggestions(emptySuggestionList); + } else if (res.suggestions.length === 0) { + updateSuggestions(emptySuggestionList); + } 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 (appsEnabled) { + 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} `; + } + + onChangeText(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(() => { + onChangeText(completedDraft.replace(`//${command} `, `/${command} `)); + }); + } + }, [onChangeText, serverUrl]); + + const renderItem = useCallback(({item}: {item: AutocompleteSuggestion}) => ( + + ), [completeSuggestion]); + + useEffect(() => { + if (value[0] !== '/') { + updateSuggestions(emptySuggestionList); + return; + } + + if (!commands) { + fetchCommands(serverUrl, currentTeamId).then((res) => { + if (res.error) { + setCommands(emptyCommandList); + } else { + setCommands(res.commands.filter(commandFilter)); + } + }); + return; + } + + if (value.indexOf(' ') === -1) { + showBaseCommands(value); + return; + } + + runFetch(serverUrl, value, currentTeamId, channelId, rootId); + }, [value, commands]); + + if (!active) { + // If we are not in an active state return null so nothing is rendered + // other components are not blocked. + return null; + } + + return ( + + ); +}; + +export default SlashSuggestion; diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx new file mode 100644 index 000000000..c5aaafaca --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx @@ -0,0 +1,170 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React 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 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 bangIcon = require('@assets/images/autocomplete/slash_command_error.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 completeSuggestion = () => { + 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 = ( + + ); + if (icon === COMMAND_SUGGESTION_ERROR) { + image = ( + + ); + } else if (icon && icon.startsWith('http')) { + image = ( + + ); + } else if (icon && icon.startsWith('data:')) { + if (icon.startsWith('data:image/svg+xml')) { + const xml = ''; // base64.decode(icon.substring('data:image/svg+xml;base64,'.length)); + image = ( + + ); + } else { + image = ( + + ); + } + } + + return ( + + + + {image} + + + {`${suggestionText}`} + {Boolean(description) && + + {description} + + } + + + + ); +}; + +export default SlashSuggestionItem; diff --git a/app/constants/apps.ts b/app/constants/apps.ts index 33546e1b6..91b79fa07 100644 --- a/app/constants/apps.ts +++ b/app/constants/apps.ts @@ -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, }; diff --git a/app/utils/apps.ts b/app/utils/apps.ts index f9d65ee85..c803af19d 100644 --- a/app/utils/apps.ts +++ b/app/utils/apps.ts @@ -175,3 +175,5 @@ export const makeCallErrorResponse = (errMessage: string) => { error: errMessage, }; }; + +export const filterEmptyOptions = (option: AppSelectOption): boolean => Boolean(option.value && !option.value.match(/^[ \t]+$/)); diff --git a/app/utils/helpers.ts b/app/utils/helpers.ts index fab3d5e62..7bdd0d859 100644 --- a/app/utils/helpers.ts +++ b/app/utils/helpers.ts @@ -62,6 +62,9 @@ export function buildQueryString(parameters: Dictionary): 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) { diff --git a/app/utils/user/index.ts b/app/utils/user/index.ts index 147e62744..728893952 100644 --- a/app/utils/user/index.ts +++ b/app/utils/user/index.ts @@ -192,8 +192,8 @@ export function confirmOutOfOfficeDisabled(intl: IntlShape, status: string, upda ); } -export function isShared(user: UserProfile): boolean { - return Boolean(user.remote_id); +export function isShared(user: UserProfile | UserModel): boolean { + return 'remote_id' in user ? Boolean(user.remote_id) : false; } export function removeUserFromList(userId: string, originalList: UserProfile[]): UserProfile[] { diff --git a/types/api/apps.d.ts b/types/api/apps.d.ts index f46ff82cd..de658a774 100644 --- a/types/api/apps.d.ts +++ b/types/api/apps.d.ts @@ -179,7 +179,7 @@ type AppField = { type AutocompleteSuggestion = { suggestion: string; - complete?: string; + complete: string; description?: string; hint?: string; iconData?: string; diff --git a/types/api/integrations.d.ts b/types/api/integrations.d.ts index 9817aac7a..c9ad79cf2 100644 --- a/types/api/integrations.d.ts +++ b/types/api/integrations.d.ts @@ -19,6 +19,7 @@ type Command = { 'display_name': string; 'description': string; 'url': string; + 'autocomplete_icon_data'?: string; }; type CommandArgs = { From 7ffcba44d6a592516647eff6c24350240000dd44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Fri, 18 Mar 2022 13:56:03 +0100 Subject: [PATCH 02/23] Link to autocomplete component and fix issues --- app/components/autocomplete/autocomplete.tsx | 29 ++-- .../app_command_parser/app_command_parser.ts | 139 +++++++++--------- .../app_command_parser/mentions.ts | 48 +++--- .../app_slash_suggestion.tsx | 77 ++++++---- .../slash_suggestion/slash_suggestion.tsx | 66 +++++---- .../slash_suggestion_item.tsx | 13 +- .../autocomplete/slash_command_error.png | Bin 0 -> 288 bytes .../autocomplete/slash_command_error@2x.png | Bin 0 -> 419 bytes .../autocomplete/slash_command_error@3x.png | Bin 0 -> 625 bytes types/api/apps.d.ts | 10 +- 10 files changed, 210 insertions(+), 172 deletions(-) create mode 100644 assets/base/images/autocomplete/slash_command_error.png create mode 100644 assets/base/images/autocomplete/slash_command_error@2x.png create mode 100644 assets/base/images/autocomplete/slash_command_error@3x.png diff --git a/app/components/autocomplete/autocomplete.tsx b/app/components/autocomplete/autocomplete.tsx index 5b82aeb9b..440a6f202 100644 --- a/app/components/autocomplete/autocomplete.tsx +++ b/app/components/autocomplete/autocomplete.tsx @@ -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 && ( - )} */} + )} {(!appsTakeOver || !isAppsEnabled) && (<> } - {/* - {(isSearch && enableDateSuggestion) && + } + {/* {(isSearch && enableDateSuggestion) && { return [{ - complete: '', - suggestion: '', - hint: this.intl.formatMessage({ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ id: 'apps.suggestion.no_suggestion', defaultMessage: 'No matching suggestions.', }), - iconData: COMMAND_SUGGESTION_ERROR, - description: '', + IconData: COMMAND_SUGGESTION_ERROR, + Description: '', }]; }; getErrorSuggestion = (parsed: ParsedCommand): AutocompleteSuggestion[] => { return [{ - complete: '', - suggestion: '', - hint: this.intl.formatMessage({ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ id: 'apps.suggestion.errors.parser_error', defaultMessage: 'Parsing error', }), - iconData: COMMAND_SUGGESTION_ERROR, - description: parsed.error, + IconData: COMMAND_SUGGESTION_ERROR, + Description: parsed.error, }]; }; @@ -940,22 +940,21 @@ export class AppCommandParser { // decorateSuggestionComplete applies the necessary modifications for a suggestion to be processed private decorateSuggestionComplete = (parsed: ParsedCommand, choice: AutocompleteSuggestion): AutocompleteSuggestion => { - if (choice.complete && choice.complete.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID)) { + if (choice.Complete && choice.Complete.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID)) { return choice as AutocompleteSuggestion; } let goBackSpace = 0; - if (choice.complete === '') { + if (choice.Complete === '') { goBackSpace = 1; } let complete = parsed.command.substring(0, parsed.incompleteStart - goBackSpace); - complete += choice.complete === undefined ? choice.suggestion : choice.complete; - choice.hint = choice.hint || ''; + complete += choice.Complete === undefined ? choice.Suggestion : choice.Complete; complete = complete.substring(1); return { ...choice, - complete, + Complete: complete, }; }; @@ -1097,11 +1096,11 @@ export class AppCommandParser { bindings.forEach((b) => { if (b.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())) { result.push({ - complete: b.label, - suggestion: b.label, - description: b.description || '', - hint: b.hint || '', - iconData: b.icon || '', + Complete: b.label, + Suggestion: b.label, + Description: b.description || '', + Hint: b.hint || '', + IconData: b.icon || '', }); } }); @@ -1187,11 +1186,11 @@ export class AppCommandParser { if (applicable) { return applicable.map((f) => { return { - complete: prefix + (f.label || f.name), - suggestion: '--' + (f.label || f.name), - description: f.description || '', - hint: f.hint || '', - iconData: parsed.binding?.icon || '', + Complete: prefix + (f.label || f.name), + Suggestion: '--' + (f.label || f.name), + Description: f.description || '', + Hint: f.hint || '', + IconData: parsed.binding?.icon || '', }; }); } @@ -1232,11 +1231,11 @@ export class AppCommandParser { const fieldName = parsed.field.modal_label || parsed.field.label || parsed.field.name; return [{ - complete, - suggestion: `${fieldName}: ${delimiter || '"'}${parsed.incomplete}${delimiter || '"'}`, - description: f.description || '', - hint: '', - iconData: parsed.binding?.icon || '', + Complete: complete, + Suggestion: `${fieldName}: ${delimiter || '"'}${parsed.incomplete}${delimiter || '"'}`, + Description: f.description || '', + Hint: '', + IconData: parsed.binding?.icon || '', }]; }; @@ -1246,14 +1245,14 @@ export class AppCommandParser { const opts = f.options?.filter((opt) => opt.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())); if (!opts?.length) { return [{ - complete: '', - suggestion: '', - hint: this.intl.formatMessage({ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ id: 'apps.suggestion.no_static', defaultMessage: 'No matching options.', }), - description: '', - iconData: COMMAND_SUGGESTION_ERROR, + Description: '', + IconData: COMMAND_SUGGESTION_ERROR, }]; } return opts.map((opt) => { @@ -1264,11 +1263,11 @@ export class AppCommandParser { complete = '`' + complete + '`'; } return { - complete, - suggestion: opt.label, - hint: f.hint || '', - description: f.description || '', - iconData: opt.icon_data || parsed.binding?.icon || '', + Complete: complete, + Suggestion: opt.label, + Hint: f.hint || '', + Description: f.description || '', + IconData: opt.icon_data || parsed.binding?.icon || '', }; }); }; @@ -1331,14 +1330,14 @@ export class AppCommandParser { items = items?.filter(filterEmptyOptions); if (!items?.length) { return [{ - complete: '', - suggestion: '', - hint: this.intl.formatMessage({ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ id: 'apps.suggestion.no_static', defaultMessage: 'No matching options.', }), - iconData: '', - description: this.intl.formatMessage({ + IconData: '', + Description: this.intl.formatMessage({ id: 'apps.suggestion.no_dynamic', defaultMessage: 'No data was returned for dynamic suggestions', }), @@ -1353,11 +1352,11 @@ export class AppCommandParser { complete = '`' + complete + '`'; } return ({ - complete, - description: s.label || s.value, - suggestion: s.value, - hint: '', - iconData: s.icon_data || parsed.binding?.icon || '', + Complete: complete, + Description: s.label || s.value, + Suggestion: s.value, + Hint: '', + IconData: s.icon_data || parsed.binding?.icon || '', }); }); }; @@ -1370,14 +1369,14 @@ export class AppCommandParser { error: message, }); return [{ - complete: '', - suggestion: '', - hint: this.intl.formatMessage({ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ id: 'apps.suggestion.dynamic.error', defaultMessage: 'Dynamic select error', }), - iconData: COMMAND_SUGGESTION_ERROR, - description: errMsg, + IconData: COMMAND_SUGGESTION_ERROR, + Description: errMsg, }]; }; @@ -1405,20 +1404,20 @@ export class AppCommandParser { if ('true'.startsWith(parsed.incomplete)) { suggestions.push({ - complete: 'true', - suggestion: 'true', - description: parsed.field?.description || '', - hint: parsed.field?.hint || '', - iconData: parsed.binding?.icon || '', + Complete: 'true', + Suggestion: 'true', + Description: parsed.field?.description || '', + Hint: parsed.field?.hint || '', + IconData: parsed.binding?.icon || '', }); } if ('false'.startsWith(parsed.incomplete)) { suggestions.push({ - complete: 'false', - suggestion: 'false', - description: parsed.field?.description || '', - hint: parsed.field?.hint || '', - iconData: parsed.binding?.icon || '', + Complete: 'false', + Suggestion: 'false', + Description: parsed.field?.description || '', + Hint: parsed.field?.hint || '', + IconData: parsed.binding?.icon || '', }); } return suggestions; diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts index a11f71082..a1577c5ab 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts @@ -13,11 +13,11 @@ export async function inTextMentionSuggestions(serverUrl: string, pretext: strin 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; + let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + u.Complete : u.Complete; if (delimiter) { complete = delimiter + complete; } - u.complete = complete; + u.Complete = complete; }); return users; } @@ -26,11 +26,11 @@ export async function inTextMentionSuggestions(serverUrl: string, pretext: strin 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; + let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + c.Complete : c.Complete; if (delimiter) { complete = delimiter + complete; } - c.complete = complete; + c.Complete = complete; }); return channels; } @@ -40,11 +40,11 @@ export async function inTextMentionSuggestions(serverUrl: string, pretext: strin export async function getUserSuggestions(usersAutocomplete?: {users: UserProfile[]; out_of_channel?: UserProfile[]}): Promise { const notFoundSuggestions = [{ - complete: '', - suggestion: '', - description: 'No user found', - hint: '', - iconData: '', + Complete: '', + Suggestion: '', + Description: 'No user found', + Hint: '', + IconData: '', }]; if (!usersAutocomplete) { return notFoundSuggestions; @@ -67,11 +67,11 @@ export async function getUserSuggestions(usersAutocomplete?: {users: UserProfile export async function getChannelSuggestions(channels?: Channel[]): Promise { const notFoundSuggestion = [{ - complete: '', - suggestion: '', - description: 'No channel found', - hint: '', - iconData: '', + Complete: '', + Suggestion: '', + Description: 'No channel found', + Hint: '', + IconData: '', }]; if (!channels) { return notFoundSuggestion; @@ -82,11 +82,11 @@ export async function getChannelSuggestions(channels?: Channel[]): Promise { return { - complete: '~' + c.name, - suggestion: '', - description: '', - hint: '', - iconData: '', + Complete: '~' + c.name, + Suggestion: '', + Description: '', + Hint: '', + IconData: '', type: COMMAND_SUGGESTION_CHANNEL, item: c, }; @@ -97,11 +97,11 @@ export async function getChannelSuggestions(channels?: Channel[]): Promise void; - onResultCountChange: (count: number) => void; + updateValue: (text: string) => void; + onShowingChange: (c: boolean) => void; value: string; nestedScrollEnabled?: boolean; rootId?: string; channelId: string; - appsEnabled: boolean; + isAppsEnabled: boolean; }; -const keyExtractor = (item: ExtendedAutocompleteSuggestion): string => (item.suggestion || '') + item.type + item.item; +const keyExtractor = (item: ExtendedAutocompleteSuggestion): string => item.Suggestion + item.type + item.item; const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { return { @@ -47,28 +48,40 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { }; }); +const emptySuggestonList: AutocompleteSuggestion[] = []; + const AppSlashSuggestion = ({ channelId, currentTeamId, rootId, value = '', - appsEnabled, + isAppsEnabled, maxListHeight, nestedScrollEnabled, - onChangeText, - onResultCountChange, + updateValue, + onShowingChange, }: Props) => { const intl = useIntl(); const theme = useTheme(); const serverUrl = useServerUrl(); const appCommandParser = useRef(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme)); - const [dataSource, setDataSource] = useState([]); - const active = appsEnabled && Boolean(dataSource.length); + const [dataSource, setDataSource] = useState(emptySuggestonList); + const active = isAppsEnabled && Boolean(dataSource.length); const style = getStyleFromTheme(theme); + const mounted = useRef(false); + + 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); - onResultCountChange(matches.length); + onShowingChange(Boolean(matches.length)); }; const completeSuggestion = (command: string) => { @@ -81,13 +94,13 @@ const AppSlashSuggestion = ({ completedDraft = `//${command} `; } - onChangeText(completedDraft); + 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(() => { - onChangeText(completedDraft.replace(`//${command} `, `/${command} `)); + updateValue(completedDraft.replace(`//${command} `, `/${command} `)); }); } }; @@ -113,7 +126,7 @@ const AppSlashSuggestion = ({ return ( ); @@ -124,19 +137,19 @@ const AppSlashSuggestion = ({ return ( ); default: return ( ); } @@ -147,31 +160,35 @@ const AppSlashSuggestion = ({ return appCommandParser.current.isAppCommand(pretext); }; - const fetchAndShowAppCommandSuggestions = async (pretext: string, channelID: string, teamID = '', rootID?: string) => { - appCommandParser.current.setChannelContext(channelID, teamID, rootID); - const suggestions = await appCommandParser.current.getSuggestions(pretext); - updateSuggestions(suggestions); - }; - useEffect(() => { if (value[0] !== '/') { - setDataSource([]); - onResultCountChange(0); + fetchAndShowAppCommandSuggestions.cancel(); + updateSuggestions(emptySuggestonList); return; } if (value.indexOf(' ') === -1) { - setDataSource([]); + // Let slash command suggestions handle base commands. + fetchAndShowAppCommandSuggestions.cancel(); + updateSuggestions(emptySuggestonList); return; } if (!isAppCommand(value, channelId, currentTeamId, rootId)) { - setDataSource([]); + 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. diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx index e54a3296a..80d20838b 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx @@ -47,30 +47,29 @@ const filterCommands = (matchTerm: string, commands: Command[]): AutocompleteSug return command.display_name.startsWith(matchTerm) || command.trigger.startsWith(matchTerm); }); - return data.map((item) => { + return data.map((command) => { return { - complete: item.trigger, - suggestion: '/' + item.trigger, - hint: item.auto_complete_hint, - description: item.auto_complete_desc, - iconData: item.icon_url || item.autocomplete_icon_data || '', + 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 || ''; +const keyExtractor = (item: Command & AutocompleteSuggestion): string => item.id || item.Suggestion; type Props = { currentTeamId: string; - commands: Command[]; maxListHeight?: number; - onChangeText: (text: string) => void; - onResultCountChange: (count: number) => void; + updateValue: (text: string) => void; + onShowingChange: (c: boolean) => void; value: string; nestedScrollEnabled?: boolean; rootId?: string; channelId: string; - appsEnabled: boolean; + isAppsEnabled: boolean; }; const emptyCommandList: Command[] = []; @@ -80,11 +79,11 @@ const SlashSuggestion = ({ channelId, currentTeamId, rootId, - onResultCountChange, - appsEnabled, + onShowingChange, + isAppsEnabled, maxListHeight, nestedScrollEnabled, - onChangeText, + updateValue, value = '', }: Props) => { const intl = useIntl(); @@ -92,6 +91,7 @@ const SlashSuggestion = ({ const style = getStyleFromTheme(theme); const serverUrl = useServerUrl(); const appCommandParser = useRef(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme)); + const mounted = useRef(false); const [dataSource, setDataSource] = useState(emptySuggestionList); const [commands, setCommands] = useState(); @@ -100,12 +100,15 @@ const SlashSuggestion = ({ const updateSuggestions = useCallback((matches: AutocompleteSuggestion[]) => { setDataSource(matches); - onResultCountChange(matches.length); - }, [onResultCountChange]); + 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) { @@ -127,7 +130,7 @@ const SlashSuggestion = ({ const showBaseCommands = (text: string) => { let matches: AutocompleteSuggestion[] = []; - if (appsEnabled) { + if (isAppsEnabled) { const appCommands = getAppBaseCommandSuggestions(text); matches = matches.concat(appCommands); } @@ -135,10 +138,10 @@ const SlashSuggestion = ({ matches = matches.concat(filterCommands(text.substring(1), commands!)); matches.sort((match1, match2) => { - if (match1.suggestion === match2.suggestion) { + if (match1.Suggestion === match2.Suggestion) { return 0; } - return match1.suggestion > match2.suggestion ? 1 : -1; + return match1.Suggestion > match2.Suggestion ? 1 : -1; }); updateSuggestions(matches); @@ -154,30 +157,31 @@ const SlashSuggestion = ({ completedDraft = `//${command} `; } - onChangeText(completedDraft); + 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(() => { - onChangeText(completedDraft.replace(`//${command} `, `/${command} `)); + updateValue(completedDraft.replace(`//${command} `, `/${command} `)); }); } - }, [onChangeText, serverUrl]); + }, [updateValue, serverUrl]); const renderItem = useCallback(({item}: {item: AutocompleteSuggestion}) => ( ), [completeSuggestion]); useEffect(() => { if (value[0] !== '/') { + runFetch.cancel(); updateSuggestions(emptySuggestionList); return; } @@ -194,6 +198,7 @@ const SlashSuggestion = ({ } if (value.indexOf(' ') === -1) { + runFetch.cancel(); showBaseCommands(value); return; } @@ -201,6 +206,13 @@ const SlashSuggestion = ({ 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. diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx index c5aaafaca..bf0c35132 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx @@ -59,11 +59,11 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { type Props = { complete: string; - description?: string; - hint?: string; + description: string; + hint: string; onPress: (complete: string) => void; - suggestion?: string; - icon?: string; + suggestion: string; + icon: string; } const SlashSuggestionItem = ({ @@ -112,15 +112,16 @@ const SlashSuggestionItem = ({ source={bangIcon} /> ); - } else if (icon && icon.startsWith('http')) { + } else if (icon.startsWith('http')) { image = ( ); - } else if (icon && icon.startsWith('data:')) { + } else if (icon.startsWith('data:')) { if (icon.startsWith('data:image/svg+xml')) { + // TODO: What base64 library should we use? Security implications on doing things like this? const xml = ''; // base64.decode(icon.substring('data:image/svg+xml;base64,'.length)); image = ( l*8o|0J>k`CC0*978G?_f9nAJES1d`u|bO^0@p4Ehc6&jc`cJXW z_vy~|9~_VJvw7&{_)GD+-dgJQ{`7Y`3@( z1Jm{s0OJ-j@|I&3OW7_9>~*+~=^Z`_NJPJoob8SR9@;NwyG~cR3A-K4{E1xjqkPL3 z4HxDn`0MoEIrsQ8T()hy5Cg~Kr)K~+`IGWCpMT7_9QGX6b$t`UI=P9DU7rlBs_GV4 zRd*)?CBLpDP^tHoa}c~=y!}?(yCw;C5%u zyI8>2ksao@GwgosF2KG?Py-u{+eZLXain|g^`0MqAPGcit=Fy?AC@OIM86 z;G7&AthI51vF75zLI_H$36o=jY1NcaaU5%BHB##0D?U1MR})3%4TvIx!%z~oiY&g> z0BcuLgVQTxak=;Ca5yX;j7it_RZCydEN~*bZEEtuVuNkAX^bGZnGM$ZpIVLR%;ZYx z^1wpK%yw!m%WJjGHYtB1xxqGVHt*y^02_>cZAmW6t2hiZEskwgos~&DDrl~ZL+LZYxpzn zT)Ef~X!rYc7a@Za&1TcF@1L*m4pqSkoTNk; Date: Mon, 21 Mar 2022 12:53:59 -0700 Subject: [PATCH 03/23] Detox/E2E: Server List e2e in Gekidou --- app/screens/edit_server/form.tsx | 8 +- app/screens/edit_server/header.tsx | 4 +- app/screens/edit_server/index.tsx | 2 +- detox/e2e/support/server_api/user.ts | 2 +- detox/e2e/support/ui/component/alert.ts | 6 + detox/e2e/support/ui/screen/edit_server.ts | 36 +++ detox/e2e/support/ui/screen/index.ts | 2 + .../e2e/test/server_login/server_list.e2e.ts | 230 ++++++++++++++++++ detox/e2e/test/smoke_test/server_login.e2e.ts | 31 ++- 9 files changed, 312 insertions(+), 9 deletions(-) create mode 100644 detox/e2e/support/ui/screen/edit_server.ts create mode 100644 detox/e2e/test/server_login/server_list.e2e.ts diff --git a/app/screens/edit_server/form.tsx b/app/screens/edit_server/form.tsx index 1a5c83317..cce18c5a4 100644 --- a/app/screens/edit_server/form.tsx +++ b/app/screens/edit_server/form.tsx @@ -145,6 +145,8 @@ const EditServerForm = ({ ); } + const saveButtonTestId = buttonDisabled ? 'edit_server_form.save.button.disabled' : 'edit_server_form.save.button'; + return ( @@ -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} { defaultMessage='Edit server name' id='edit_server.title' style={styles.title} - testID='edit_server.title' + testID='edit_server_header.title' /> ); diff --git a/app/screens/edit_server/index.tsx b/app/screens/edit_server/index.tsx index 6bacbd541..ee2b43cb2 100644 --- a/app/screens/edit_server/index.tsx +++ b/app/screens/edit_server/index.tsx @@ -99,7 +99,7 @@ const EditServer = ({closeButtonId, componentId, server, theme}: ServerProps) => { + 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(); diff --git a/detox/e2e/support/ui/screen/edit_server.ts b/detox/e2e/support/ui/screen/edit_server.ts new file mode 100644 index 000000000..a3f249a91 --- /dev/null +++ b/detox/e2e/support/ui/screen/edit_server.ts @@ -0,0 +1,36 @@ +// 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', + 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)); + 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; diff --git a/detox/e2e/support/ui/screen/index.ts b/detox/e2e/support/ui/screen/index.ts index b9b6a4e9d..6218be871 100644 --- a/detox/e2e/support/ui/screen/index.ts +++ b/detox/e2e/support/ui/screen/index.ts @@ -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, diff --git a/detox/e2e/test/server_login/server_list.e2e.ts b/detox/e2e/test/server_login/server_list.e2e.ts new file mode 100644 index 000000000..5bf4dad1a --- /dev/null +++ b/detox/e2e/test/server_login/server_list.e2e.ts @@ -0,0 +1,230 @@ +// 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; + let serverTwoUser; + let serverThreeUser; + + 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); + + // # 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); + + // # 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); + + // # 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); + + // # 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 User.apiAdminLogin(siteOneUrl); + ({user: serverOneUser} = await Setup.apiInit(siteOneUrl)); + 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); + + // # 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 User.apiAdminLogin(siteOneUrl); + ({user: serverOneUser} = await Setup.apiInit(siteOneUrl)); + await expect(ServerScreen.headerTitleAddServer).toBeVisible(); + await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName); + await LoginScreen.login(serverOneUser); + }); +}); diff --git a/detox/e2e/test/smoke_test/server_login.e2e.ts b/detox/e2e/test/smoke_test/server_login.e2e.ts index 557018276..ef18d5cfa 100644 --- a/detox/e2e/test/smoke_test/server_login.e2e.ts +++ b/detox/e2e/test/smoke_test/server_login.e2e.ts @@ -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,24 @@ 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); + }); }); From 4657a3a81299ec3f428523ea26305c35e190c16f Mon Sep 17 00:00:00 2001 From: Joseph Baylon Date: Mon, 21 Mar 2022 13:02:08 -0700 Subject: [PATCH 04/23] Fix second step --- detox/e2e/test/smoke_test/server_login.e2e.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/detox/e2e/test/smoke_test/server_login.e2e.ts b/detox/e2e/test/smoke_test/server_login.e2e.ts index ef18d5cfa..2004676e7 100644 --- a/detox/e2e/test/smoke_test/server_login.e2e.ts +++ b/detox/e2e/test/smoke_test/server_login.e2e.ts @@ -73,5 +73,9 @@ describe('Server Login', () => { // * 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(); }); }); From 368f6aa08d30cfa99705290d71400bf9341f1d9c Mon Sep 17 00:00:00 2001 From: Shaz MJ Date: Tue, 22 Mar 2022 09:42:47 +1100 Subject: [PATCH 05/23] Handes visibility preference setting for dm/gm --- .../categories/body/category_body.test.tsx | 1 + .../categories/body/category_body.tsx | 18 ++++++--- .../channel_list/categories/body/index.ts | 40 +++++++++++++++---- .../channel_list/categories/categories.tsx | 5 ++- .../channel_list/categories/index.ts | 6 ++- app/constants/preferences.ts | 3 +- app/queries/servers/channel.ts | 4 ++ app/queries/servers/preference.ts | 16 ++++++++ 8 files changed, 78 insertions(+), 15 deletions(-) diff --git a/app/components/channel_list/categories/body/category_body.test.tsx b/app/components/channel_list/categories/body/category_body.test.tsx index 220ac79ae..3a32e3bf2 100644 --- a/app/components/channel_list/categories/body/category_body.test.tsx +++ b/app/components/channel_list/categories/body/category_body.test.tsx @@ -36,6 +36,7 @@ describe('components/channel_list/categories/body', () => { category={category} locale={DEFAULT_LOCALE} currentChannelId={''} + currentUserId={''} />, {database}, ); diff --git a/app/components/channel_list/categories/body/category_body.tsx b/app/components/channel_list/categories/body/category_body.tsx index 38f3787a9..c27f2ac00 100644 --- a/app/components/channel_list/categories/body/category_body.tsx +++ b/app/components/channel_list/categories/body/category_body.tsx @@ -11,19 +11,27 @@ import type CategoryModel from '@typings/database/models/servers/category'; type Props = { currentChannelId: string; sortedIds: string[]; + hiddenChannelIds: string[]; category: CategoryModel; limit: number; }; const extractKey = (item: string) => item; -const CategoryBody = ({currentChannelId, sortedIds, category, limit}: Props) => { +const CategoryBody = ({currentChannelId, sortedIds, category, hiddenChannelIds, limit}: Props) => { const ids = useMemo(() => { - if (category.type === 'direct_messages' && limit > 0) { - return sortedIds.slice(0, limit - 1); + let filteredIds = sortedIds; + + // Remove all closed gm/dms + if (hiddenChannelIds.length) { + filteredIds = sortedIds.filter((id) => !hiddenChannelIds.includes(id)); } - return sortedIds; - }, [category.type, limit]); + + if (category.type === 'direct_messages' && limit > 0) { + return filteredIds.slice(0, limit - 1); + } + return filteredIds; + }, [category.type, limit, hiddenChannelIds]); const ChannelItem = useCallback(({item}: {item: string}) => { return ( diff --git a/app/components/channel_list/categories/body/index.ts b/app/components/channel_list/categories/body/index.ts index 9acf7d37e..3ca076ef4 100644 --- a/app/components/channel_list/categories/body/index.ts +++ b/app/components/channel_list/categories/body/index.ts @@ -8,6 +8,9 @@ import {combineLatest, of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; import {MM_TABLES} from '@app/constants/database'; +import {queryChannelsByNames} from '@app/queries/servers/channel'; +import {queryPreferencesByCategory} from '@app/queries/servers/preference'; +import {getDirectChannelName} from '@app/utils/channel'; import {General, Preferences} from '@constants'; import {WithDatabaseArgs} from '@typings/database/database'; @@ -22,7 +25,7 @@ type ChannelData = Pick & { isMuted: boolean; }; -const {SERVER: {MY_CHANNEL_SETTINGS, PREFERENCE}} = MM_TABLES; +const {SERVER: {MY_CHANNEL_SETTINGS}} = MM_TABLES; const sortAlpha = (locale: string, a: ChannelData, b: ChannelData) => { if (a.isMuted && !b.isMuted) { @@ -82,19 +85,37 @@ const getSortedIds = (database: Database, category: CategoryModel, locale: strin } }; -const enhance = withObservables(['category'], ({category, locale, database}: {category: CategoryModel; locale: string} & WithDatabaseArgs) => { +const mapPrefName = (prefs: PreferenceModel[]) => of$(prefs.map((p) => p.name)); + +const mapChannelIds = (channels: ChannelModel[]) => of$(channels.map((c) => c.id)); + +const enhance = withObservables(['category'], ({category, locale, database, currentUserId}: {category: CategoryModel; locale: string; currentUserId: string} & WithDatabaseArgs) => { const observedCategory = category.observe(); const sortedIds = observedCategory.pipe( switchMap((c) => getSortedIds(database, c, locale)), ); + const dmMap = (p: PreferenceModel) => getDirectChannelName(p.name, currentUserId); + + const hiddenDmIds = queryPreferencesByCategory(database, Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, undefined, 'false'). + observe().pipe( + switchMap((prefs: PreferenceModel[]) => { + const names = prefs.map(dmMap); + const channels = queryChannelsByNames(database, names).observe(); + + return channels.pipe( + switchMap(mapChannelIds), + ); + }), + ); + + const hiddenGmIds = queryPreferencesByCategory(database, Preferences.CATEGORY_GROUP_CHANNEL_SHOW, undefined, 'false'). + observe().pipe(switchMap(mapPrefName)); + let limit = of$(0); if (category.type === 'direct_messages') { - limit = database.get(PREFERENCE). - query( - Q.where('category', Preferences.CATEGORY_SIDEBAR_SETTINGS), - Q.where('name', 'limit_visible_dms_gms'), - ).observe().pipe( + limit = queryPreferencesByCategory(database, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.CHANNEL_SIDEBAR_LIMIT_DMS). + observe().pipe( switchMap( (val) => { if (val[0]) { @@ -107,8 +128,13 @@ const enhance = withObservables(['category'], ({category, locale, database}: {ca ); } + const hiddenChannelIds = combineLatest([hiddenDmIds, hiddenGmIds]).pipe(switchMap( + ([a, b]) => of$(a.concat(b)), + )); + return { limit, + hiddenChannelIds, sortedIds, category: observedCategory, }; diff --git a/app/components/channel_list/categories/categories.tsx b/app/components/channel_list/categories/categories.tsx index 406b12294..5718fd42b 100644 --- a/app/components/channel_list/categories/categories.tsx +++ b/app/components/channel_list/categories/categories.tsx @@ -14,6 +14,7 @@ import type {CategoryModel} from '@database/models/server'; type Props = { categories: CategoryModel[]; currentChannelId: string; + currentUserId: string; } const styles = StyleSheet.create({ @@ -24,8 +25,9 @@ const styles = StyleSheet.create({ const extractKey = (item: CategoryModel) => item.id; -const Categories = ({categories, currentChannelId}: Props) => { +const Categories = ({categories, currentChannelId, currentUserId}: Props) => { const intl = useIntl(); + const renderCategory = useCallback((data: {item: CategoryModel}) => { return ( <> @@ -33,6 +35,7 @@ const Categories = ({categories, currentChannelId}: Props) => { diff --git a/app/components/channel_list/categories/index.ts b/app/components/channel_list/categories/index.ts index cf6010965..7de7c61cb 100644 --- a/app/components/channel_list/categories/index.ts +++ b/app/components/channel_list/categories/index.ts @@ -16,7 +16,7 @@ import type CategoryModel from '@typings/database/models/servers/category'; import type SystemModel from '@typings/database/models/servers/system'; const {SERVER: {CATEGORY, SYSTEM}} = MM_TABLES; -const {CURRENT_CHANNEL_ID} = SYSTEM_IDENTIFIERS; +const {CURRENT_CHANNEL_ID, CURRENT_USER_ID} = SYSTEM_IDENTIFIERS; type WithDatabaseProps = {currentTeamId: string } & WithDatabaseArgs @@ -26,6 +26,9 @@ const enhanced = withObservables( const currentChannelId = database.get(SYSTEM).findAndObserve(CURRENT_CHANNEL_ID).pipe( switchMap(({value}) => of$(value)), ); + const currentUserId = database.get(SYSTEM).findAndObserve(CURRENT_USER_ID).pipe( + switchMap(({value}) => of$(value)), + ); const categories = database.get(CATEGORY).query( Q.where('team_id', currentTeamId), ).observeWithColumns(['sort_order']); @@ -33,6 +36,7 @@ const enhanced = withObservables( return { currentChannelId, categories, + currentUserId, }; }); diff --git a/app/constants/preferences.ts b/app/constants/preferences.ts index 0a962329a..c76a1d17b 100644 --- a/app/constants/preferences.ts +++ b/app/constants/preferences.ts @@ -11,6 +11,7 @@ const Preferences: Record = { CATEGORY_FAVORITE_CHANNEL: 'favorite_channel', CATEGORY_AUTO_RESET_MANUAL_STATUS: 'auto_reset_manual_status', CATEGORY_NOTIFICATIONS: 'notifications', + COMMENTS: 'comments', COMMENTS_ANY: 'any', COMMENTS_ROOT: 'root', @@ -35,7 +36,7 @@ const Preferences: Record = { USE_MILITARY_TIME: 'use_military_time', CATEGORY_SIDEBAR_SETTINGS: 'sidebar_settings', CHANNEL_SIDEBAR_ORGANIZATION: 'channel_sidebar_organization', - CHANNEL_SIDEBAR_AUTOCLOSE_DMS: 'close_unused_direct_messages', + CHANNEL_SIDEBAR_LIMIT_DMS: 'limit_visible_dms_gms', AUTOCLOSE_DMS_ENABLED: 'after_seven_days', CATEGORY_ADVANCED_SETTINGS: 'advanced_settings', ADVANCED_FILTER_JOIN_LEAVE: 'join_leave', diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 48a9c0308..2683db156 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -247,3 +247,7 @@ export const addChannelMembership = async (operator: ServerDataOperator, userId: export const queryAllMyChannelMembershipIds = async (database: Database, userId: string) => { return database.get(CHANNEL_MEMBERSHIP).query(Q.where('user_id', Q.eq(userId))).fetchIds(); }; + +export const queryChannelsByNames = (database: Database, names: string[]) => { + return database.get(CHANNEL).query(Q.where('name', Q.oneOf(names))); +}; diff --git a/app/queries/servers/preference.ts b/app/queries/servers/preference.ts index 3290f04dc..d4fd7acc0 100644 --- a/app/queries/servers/preference.ts +++ b/app/queries/servers/preference.ts @@ -12,6 +12,8 @@ import {queryCurrentTeamId} from './system'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type PreferenceModel from '@typings/database/models/servers/preference'; +const {SERVER: {PREFERENCE}} = MM_TABLES; + export const prepareMyPreferences = (operator: ServerDataOperator, preferences: PreferenceType[], sync = false) => { try { return operator.handlePreferences({ @@ -65,3 +67,17 @@ export const deletePreferences = async (database: ServerDatabase, preferences: P return false; } }; + +export const queryPreferencesByCategory = (database: Database, category: string, name?: string, value?: string) => { + const clauses = [Q.where('category', category)]; + + if (typeof name === 'string') { + clauses.push(Q.where('name', name)); + } + + if (typeof value === 'string') { + clauses.push(Q.where('value', value)); + } + + return database.get(PREFERENCE).query(...clauses); +}; From 7431fd41202d823fc3c2c3a033804398dce6cde6 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Fri, 18 Mar 2022 13:30:28 -0300 Subject: [PATCH 06/23] combine reactions by their first alias --- app/actions/local/reactions.ts | 12 ++-- app/actions/remote/reactions.ts | 68 +++++++++++-------- .../post/body/reactions/reactions.tsx | 19 ++++-- app/queries/servers/reaction.ts | 15 ++++ app/utils/emoji/helpers.ts | 4 ++ 5 files changed, 78 insertions(+), 40 deletions(-) create mode 100644 app/queries/servers/reaction.ts diff --git a/app/actions/local/reactions.ts b/app/actions/local/reactions.ts index 846db4cdb..3303c712e 100644 --- a/app/actions/local/reactions.ts +++ b/app/actions/local/reactions.ts @@ -3,6 +3,7 @@ import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; +import {getEmojiFirstAlias} from '@utils/emoji/helpers'; import {safeParseJSON} from '@utils/helpers'; import type SystemModel from '@typings/database/models/servers/system'; @@ -29,16 +30,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: [{ diff --git a/app/actions/remote/reactions.ts b/app/actions/remote/reactions.ts index 3444df6ab..360333be0 100644 --- a/app/actions/remote/reactions.ts +++ b/app/actions/remote/reactions.ts @@ -1,14 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Model, Q} from '@nozbe/watermelondb'; +import {Model} from '@nozbe/watermelondb'; import {addRecentReaction} from '@actions/local/reactions'; -import {MM_TABLES} from '@constants/database'; import DatabaseManager from '@database/manager'; import NetworkManager from '@init/network_manager'; import {queryRecentPostsInChannel, queryRecentPostsInThread} from '@queries/servers/post'; +import {queryReaction} from '@queries/servers/reaction'; import {queryCurrentChannelId, queryCurrentUserId} 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 queryCurrentUserId(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: Date.now(), + } as Reaction, + }; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; @@ -74,14 +87,11 @@ export const removeReaction = async (serverUrl: string, postId: string, emojiNam try { const currentUserId = await queryCurrentUserId(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 database.get(MM_TABLES.SERVER.REACTION).query( - Q.where('emoji_name', emojiName), - Q.where('post_id', postId), - Q.where('user_id', currentUserId), - ).fetch(); + const reaction = await queryReaction(database, emojiAlias, postId, currentUserId).fetch(); if (reaction.length) { await database.write(async () => { diff --git a/app/components/post_list/post/body/reactions/reactions.tsx b/app/components/post_list/post/body/reactions/reactions.tsx index f12619100..8baa12c84 100644 --- a/app/components/post_list/post/body/reactions/reactions.tsx +++ b/app/components/post_list/post/body/reactions/reactions.tsx @@ -10,6 +10,7 @@ import CompassIcon from '@components/compass_icon'; import {MAX_ALLOWED_REACTIONS} from '@constants/emoji'; import {useServerUrl} from '@context/server'; import {showModal, showModalOverCurrentContext} from '@screens/navigation'; +import {getEmojiFirstAlias} from '@utils/emoji/helpers'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -59,12 +60,12 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, const intl = useIntl(); const serverUrl = useServerUrl(); 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 +79,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); } } diff --git a/app/queries/servers/reaction.ts b/app/queries/servers/reaction.ts new file mode 100644 index 000000000..8b7176618 --- /dev/null +++ b/app/queries/servers/reaction.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database, Q} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; +const {SERVER: {REACTION}} = MM_TABLES; + +export const queryReaction = (database: Database, emojiName: string, postId: string, userId: string) => { + return database.get(REACTION).query( + Q.where('emoji_name', emojiName), + Q.where('post_id', postId), + Q.where('user_id', userId), + ); +}; diff --git a/app/utils/emoji/helpers.ts b/app/utils/emoji/helpers.ts index 0fb435f9b..f3ea5b334 100644 --- a/app/utils/emoji/helpers.ts +++ b/app/utils/emoji/helpers.ts @@ -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)!]; From 1448ee843a89e3c283910ba140e651aa4c6d7b8d Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Tue, 22 Mar 2022 14:08:49 -0300 Subject: [PATCH 07/23] Extract re-usable user_item component --- .../at_mention_item/at_mention_item.tsx | 175 ------------------ .../autocomplete/at_mention_item/index.tsx | 49 +++++ .../at_mention_item => user_item}/index.ts | 4 +- app/components/user_item/user_item.tsx | 174 +++++++++++++++++ app/utils/user/index.ts | 14 +- 5 files changed, 234 insertions(+), 182 deletions(-) delete mode 100644 app/components/autocomplete/at_mention_item/at_mention_item.tsx create mode 100644 app/components/autocomplete/at_mention_item/index.tsx rename app/components/{autocomplete/at_mention_item => user_item}/index.ts (92%) create mode 100644 app/components/user_item/user_item.tsx diff --git a/app/components/autocomplete/at_mention_item/at_mention_item.tsx b/app/components/autocomplete/at_mention_item/at_mention_item.tsx deleted file mode 100644 index b1227e778..000000000 --- a/app/components/autocomplete/at_mention_item/at_mention_item.tsx +++ /dev/null @@ -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 ( - - - - - - - {Boolean(user.is_bot) && ()} - {guest && ()} - {Boolean(name.length) && ( - - {name} - - )} - {isCurrentUser && ( - - )} - - {` @${user.username}`} - - - {isCustomStatusEnabled && !user.is_bot && customStatus && ( - - )} - {shared && ( - - )} - - - ); -}; - -export default AtMentionItem; diff --git a/app/components/autocomplete/at_mention_item/index.tsx b/app/components/autocomplete/at_mention_item/index.tsx new file mode 100644 index 000000000..ecc062a5f --- /dev/null +++ b/app/components/autocomplete/at_mention_item/index.tsx @@ -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 ( + + + + ); +}; + +export default AtMentionItem; diff --git a/app/components/autocomplete/at_mention_item/index.ts b/app/components/user_item/index.ts similarity index 92% rename from app/components/autocomplete/at_mention_item/index.ts rename to app/components/user_item/index.ts index eed265068..af20dbdce 100644 --- a/app/components/autocomplete/at_mention_item/index.ts +++ b/app/components/user_item/index.ts @@ -8,7 +8,7 @@ import {switchMap} from 'rxjs/operators'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; -import AtMentionItem from './at_mention_item'; +import UserItem from './user_item'; import type {WithDatabaseArgs} from '@typings/database/database'; import type SystemModel from '@typings/database/models/servers/system'; @@ -34,4 +34,4 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { }; }); -export default withDatabase(enhanced(AtMentionItem)); +export default withDatabase(enhanced(UserItem)); diff --git a/app/components/user_item/user_item.tsx b/app/components/user_item/user_item.tsx new file mode 100644 index 000000000..a6e91217f --- /dev/null +++ b/app/components/user_item/user_item.tsx @@ -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; + 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 ( + + + + + + {bot && } + {guest && } + {Boolean(name.length) && + + {name} + + } + {isCurrentUser && + + } + {Boolean(user) && + + {` @${user!.username}`} + + } + + {isCustomStatusEnabled && !bot && customStatus && ( + + )} + {shared && ( + + )} + + ); +}; + +export default UserItem; diff --git a/app/utils/user/index.ts b/app/utils/user/index.ts index 147e62744..e0e60b02e 100644 --- a/app/utils/user/index.ts +++ b/app/utils/user/index.ts @@ -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[] { From a0ff99f4e974ff350777820ddc51de7e837de670 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Tue, 22 Mar 2022 14:11:48 -0300 Subject: [PATCH 08/23] set maxWidth of reaction based on the amount of digits --- .../post/body/reactions/reaction.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/app/components/post_list/post/body/reactions/reaction.tsx b/app/components/post_list/post/body/reactions/reaction.tsx index 1be845a4d..087c17cef 100644 --- a/app/components/post_list/post/body/reactions/reaction.tsx +++ b/app/components/post_list/post/body/reactions/reaction.tsx @@ -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,7 +14,7 @@ type ReactionProps = { emojiName: string; highlight: boolean; onPress: (emojiName: string, highlight: boolean) => void; - onLongPress: () => void; + onLongPress: (initialEmoji: string) => void; theme: Theme; } @@ -24,7 +24,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { color: changeOpacity(theme.centerChannelColor, 0.56), ...typography('Body', 100, 'SemiBold'), }, - countContainer: {marginRight: 5}, + countContainer: {marginRight: 8}, countHighlight: { color: theme.buttonBg, }, @@ -51,6 +51,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: ReactionProps) => { const styles = getStyleSheet(theme); + const digits = String(count).length; + const containerStyle = useMemo(() => { + const minWidth = 50 + (digits * 5); + return [styles.reaction, (highlight && styles.highlight), {minWidth}]; + }, [styles.reaction, highlight, digits]); + + const handleLongPress = useCallback(() => { + onLongPress(emojiName); + }, []); const handlePress = useCallback(() => { onPress(emojiName, highlight); @@ -59,9 +68,9 @@ const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: Re return ( Date: Tue, 22 Mar 2022 14:30:44 -0300 Subject: [PATCH 09/23] constants --- app/constants/screens.ts | 23 ++++++++++++++++------- app/utils/theme/index.ts | 17 +---------------- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/app/constants/screens.ts b/app/constants/screens.ts index db43ef32d..22dd7d37a 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -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, +]; diff --git a/app/utils/theme/index.ts b/app/utils/theme/index.ts index 4bdfdadc5..1af040555 100644 --- a/app/utils/theme/index.ts +++ b/app/utils/theme/index.ts @@ -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} { From 0618a10e1c86c9bc9cd8cd5e652757ec9aa3ea72 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Tue, 22 Mar 2022 14:32:57 -0300 Subject: [PATCH 10/23] Reactions screen --- .../post/body/reactions/reactions.tsx | 36 +++++--- app/screens/index.tsx | 6 ++ app/screens/reactions/emoji_aliases/index.tsx | 44 +++++++++ app/screens/reactions/emoji_bar/index.tsx | 91 +++++++++++++++++++ app/screens/reactions/emoji_bar/item.tsx | 81 +++++++++++++++++ app/screens/reactions/index.ts | 32 +++++++ app/screens/reactions/reactions.tsx | 84 +++++++++++++++++ app/screens/reactions/reactors_list/index.tsx | 70 ++++++++++++++ .../reactions/reactors_list/reactor/index.ts | 33 +++++++ .../reactors_list/reactor/reactor.tsx | 33 +++++++ 10 files changed, 497 insertions(+), 13 deletions(-) create mode 100644 app/screens/reactions/emoji_aliases/index.tsx create mode 100644 app/screens/reactions/emoji_bar/index.tsx create mode 100644 app/screens/reactions/emoji_bar/item.tsx create mode 100644 app/screens/reactions/index.ts create mode 100644 app/screens/reactions/reactions.tsx create mode 100644 app/screens/reactions/reactors_list/index.tsx create mode 100644 app/screens/reactions/reactors_list/reactor/index.ts create mode 100644 app/screens/reactions/reactors_list/reactor/reactor.tsx diff --git a/app/components/post_list/post/body/reactions/reactions.tsx b/app/components/post_list/post/body/reactions/reactions.tsx index 8baa12c84..ff63db352 100644 --- a/app/components/post_list/post/body/reactions/reactions.tsx +++ b/app/components/post_list/post/body/reactions/reactions.tsx @@ -3,13 +3,15 @@ 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'; @@ -59,6 +61,7 @@ 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) => getEmojiFirstAlias(r.emojiName)))); const styles = getStyleSheet(theme); @@ -106,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); @@ -116,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); @@ -128,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, postId, theme]); let addMoreReactions = null; const {reactionsByName, highlightedReactions} = buildReactionsMap(); @@ -182,4 +192,4 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, ); }; -export default React.memo(Reactions); +export default Reactions; diff --git a/app/screens/index.tsx b/app/screens/index.tsx index ab684bccb..bfbb67bae 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -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; diff --git a/app/screens/reactions/emoji_aliases/index.tsx b/app/screens/reactions/emoji_aliases/index.tsx new file mode 100644 index 000000000..5e5cb6f25 --- /dev/null +++ b/app/screens/reactions/emoji_aliases/index.tsx @@ -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 ( + + + {aliases} + + + ); +}; + +export default EmojiAliases; diff --git a/app/screens/reactions/emoji_bar/index.tsx b/app/screens/reactions/emoji_bar/index.tsx new file mode 100644 index 000000000..092dbbad0 --- /dev/null +++ b/app/screens/reactions/emoji_bar/index.tsx @@ -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; + 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>(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 ( + + ); + }, [sortedReactions, emojiSelected, reactionsByName]); + + useEffect(() => { + const t = setTimeout(() => { + listRef.current?.scrollToItem({ + item: emojiSelected, + animated: false, + viewPosition: 1, + }); + }, 100); + + return () => clearTimeout(t); + }, []); + + return ( + + ); +}; + +export default EmojiBar; diff --git a/app/screens/reactions/emoji_bar/item.tsx b/app/screens/reactions/emoji_bar/item.tsx new file mode 100644 index 000000000..3464b4e3b --- /dev/null +++ b/app/screens/reactions/emoji_bar/item.tsx @@ -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); + }, [highlight]); + + return ( + + + + + + + + + ); +}; + +export default Reaction; diff --git a/app/screens/reactions/index.ts b/app/screens/reactions/index.ts new file mode 100644 index 000000000..0d76d0cd2 --- /dev/null +++ b/app/screens/reactions/index.ts @@ -0,0 +1,32 @@ +// 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 {switchMap} from 'rxjs/operators'; + +import {MM_TABLES} from '@constants/database'; + +import Reactions from './reactions'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PostModel from '@typings/database/models/servers/post'; + +type EnhancedProps = WithDatabaseArgs & { + postId: string; +} + +const {POST} = MM_TABLES.SERVER; + +const enhanced = withObservables([], ({postId, database}: EnhancedProps) => { + const post = database.get(POST).findAndObserve(postId); + + return { + reactions: post.pipe( + switchMap((p) => p.reactions.observe()), + ), + }; +}); + +export default withDatabase(enhanced(Reactions)); + diff --git a/app/screens/reactions/reactions.tsx b/app/screens/reactions/reactions.tsx new file mode 100644 index 000000000..6208e1a1e --- /dev/null +++ b/app/screens/reactions/reactions.tsx @@ -0,0 +1,84 @@ +// 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()); + }, [reactions]); + + const renderContent = useCallback(() => { + const emojiAlias = sortedReactions[index]; + + return ( + <> + + + + + ); + }, [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 ( + + ); +}; + +export default Reactions; diff --git a/app/screens/reactions/reactors_list/index.tsx b/app/screens/reactions/reactors_list/index.tsx new file mode 100644 index 000000000..032ae1e5d --- /dev/null +++ b/app/screens/reactions/reactors_list/index.tsx @@ -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 listRef = useRef(null); + const [direction, setDirection] = useState<'down' | 'up'>('down'); + 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}) => ( + + ), [reactions]); + + const onScroll = useCallback((e: NativeSyntheticEvent) => { + 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 ( + + ); +}; + +export default ReactorsList; + diff --git a/app/screens/reactions/reactors_list/reactor/index.ts b/app/screens/reactions/reactors_list/reactor/index.ts new file mode 100644 index 000000000..3a3a19056 --- /dev/null +++ b/app/screens/reactions/reactors_list/reactor/index.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES} from '@constants/database'; +import {WithDatabaseArgs} from '@typings/database/database'; + +import Reactor from './reactor'; + +import type ReactionModel from '@typings/database/models/servers/reaction'; +import type UserModel from '@typings/database/models/servers/user'; + +const {SERVER: {USER}} = MM_TABLES; + +const enhance = withObservables(['reaction'], ({database, reaction}: {reaction: ReactionModel} & WithDatabaseArgs) => { + const user = database.get(USER).query( + Q.where('id', reaction.userId), + Q.take(1), + ).observe().pipe( + switchMap((result) => (result.length ? result[0].observe() : of$(undefined))), + ); + + return { + user, + }; +}); + +export default withDatabase(enhance(Reactor)); diff --git a/app/screens/reactions/reactors_list/reactor/reactor.tsx b/app/screens/reactions/reactors_list/reactor/reactor.tsx new file mode 100644 index 000000000..ab59b878e --- /dev/null +++ b/app/screens/reactions/reactors_list/reactor/reactor.tsx @@ -0,0 +1,33 @@ +// 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: { + paddingVertical: 0, + paddingHorizontal: 0, + paddingTop: 0, + marginBottom: 8, + }, +}); + +const Reactor = ({user}: Props) => { + return ( + + ); +}; + +export default Reactor; From 41d796e2f8181a42f2d28653e6cd8168df70c6c9 Mon Sep 17 00:00:00 2001 From: Shaz MJ Date: Wed, 23 Mar 2022 08:43:59 +1100 Subject: [PATCH 11/23] Feedback --- app/components/channel_list/categories/body/index.ts | 8 +++++--- app/constants/preferences.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/components/channel_list/categories/body/index.ts b/app/components/channel_list/categories/body/index.ts index 3ca076ef4..368a7f7f8 100644 --- a/app/components/channel_list/categories/body/index.ts +++ b/app/components/channel_list/categories/body/index.ts @@ -89,7 +89,9 @@ const mapPrefName = (prefs: PreferenceModel[]) => of$(prefs.map((p) => p.name)); const mapChannelIds = (channels: ChannelModel[]) => of$(channels.map((c) => c.id)); -const enhance = withObservables(['category'], ({category, locale, database, currentUserId}: {category: CategoryModel; locale: string; currentUserId: string} & WithDatabaseArgs) => { +type EnhanceProps = {category: CategoryModel; locale: string; currentUserId: string} & WithDatabaseArgs + +const enhance = withObservables(['category'], ({category, locale, database, currentUserId}: EnhanceProps) => { const observedCategory = category.observe(); const sortedIds = observedCategory.pipe( switchMap((c) => getSortedIds(database, c, locale)), @@ -112,7 +114,7 @@ const enhance = withObservables(['category'], ({category, locale, database, curr const hiddenGmIds = queryPreferencesByCategory(database, Preferences.CATEGORY_GROUP_CHANNEL_SHOW, undefined, 'false'). observe().pipe(switchMap(mapPrefName)); - let limit = of$(0); + let limit = of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT); if (category.type === 'direct_messages') { limit = queryPreferencesByCategory(database, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.CHANNEL_SIDEBAR_LIMIT_DMS). observe().pipe( @@ -122,7 +124,7 @@ const enhance = withObservables(['category'], ({category, locale, database, curr return of$(parseInt(val[0].value, 10)); } - return of$(0); + return of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT); }, ), ); diff --git a/app/constants/preferences.ts b/app/constants/preferences.ts index c76a1d17b..ba419d49a 100644 --- a/app/constants/preferences.ts +++ b/app/constants/preferences.ts @@ -11,7 +11,6 @@ const Preferences: Record = { CATEGORY_FAVORITE_CHANNEL: 'favorite_channel', CATEGORY_AUTO_RESET_MANUAL_STATUS: 'auto_reset_manual_status', CATEGORY_NOTIFICATIONS: 'notifications', - COMMENTS: 'comments', COMMENTS_ANY: 'any', COMMENTS_ROOT: 'root', @@ -37,6 +36,7 @@ const Preferences: Record = { CATEGORY_SIDEBAR_SETTINGS: 'sidebar_settings', CHANNEL_SIDEBAR_ORGANIZATION: 'channel_sidebar_organization', CHANNEL_SIDEBAR_LIMIT_DMS: 'limit_visible_dms_gms', + CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT: 20, AUTOCLOSE_DMS_ENABLED: 'after_seven_days', CATEGORY_ADVANCED_SETTINGS: 'advanced_settings', ADVANCED_FILTER_JOIN_LEAVE: 'join_leave', From 735e9a3925a1ff9fed0123a9dd05acc3ef90330e Mon Sep 17 00:00:00 2001 From: Shaz MJ Date: Wed, 23 Mar 2022 12:21:53 +1100 Subject: [PATCH 12/23] Adds mention badges to channel list items --- .../channel/__snapshots__/badge.test.tsx.snap | 38 ++++++++++++ .../categories/body/channel/badge.test.tsx | 20 ++++++ .../categories/body/channel/badge.tsx | 62 +++++++++++++++++++ .../body/channel/channel_list_item.tsx | 11 +++- 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 app/components/channel_list/categories/body/channel/__snapshots__/badge.test.tsx.snap create mode 100644 app/components/channel_list/categories/body/channel/badge.test.tsx create mode 100644 app/components/channel_list/categories/body/channel/badge.tsx diff --git a/app/components/channel_list/categories/body/channel/__snapshots__/badge.test.tsx.snap b/app/components/channel_list/categories/body/channel/__snapshots__/badge.test.tsx.snap new file mode 100644 index 000000000..b9aeb9ca7 --- /dev/null +++ b/app/components/channel_list/categories/body/channel/__snapshots__/badge.test.tsx.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/channel_list/categories/body/channel/badge should match snapshot 1`] = ` + + + 10 + + +`; diff --git a/app/components/channel_list/categories/body/channel/badge.test.tsx b/app/components/channel_list/categories/body/channel/badge.test.tsx new file mode 100644 index 000000000..0696f225e --- /dev/null +++ b/app/components/channel_list/categories/body/channel/badge.test.tsx @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithIntlAndTheme} from '@test/intl-test-helper'; + +import Badge from './badge'; + +describe('components/channel_list/categories/body/channel/badge', () => { + it('should match snapshot', () => { + const wrapper = renderWithIntlAndTheme( + , + ); + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/channel_list/categories/body/channel/badge.tsx b/app/components/channel_list/categories/body/channel/badge.tsx new file mode 100644 index 000000000..7a7270f38 --- /dev/null +++ b/app/components/channel_list/categories/body/channel/badge.tsx @@ -0,0 +1,62 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {Text, View} from 'react-native'; + +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + badge: { + backgroundColor: theme.mentionBg, + borderRadius: 10, + height: 20, + marginTop: 5, + paddingHorizontal: 8, + alignItems: 'center', + justifyContent: 'center', + textAlignVertical: 'center', + }, + text: { + color: theme.mentionColor, + ...typography('Body', 75), + }, + mutedBadge: { + backgroundColor: changeOpacity(theme.mentionBg, 0.4), + }, + mutedText: { + color: changeOpacity(theme.mentionColor, 0.4), + }, +})); + +const Badge = ({count, muted}: {count: number; muted: boolean}) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + const viewStyle = useMemo(() => [ + styles.badge, + muted && styles.mutedBadge, + ], [muted]); + + const textStyle = useMemo(() => [ + styles.text, + + muted && styles.mutedText, + ], [muted]); + + if (!count) { + return null; + } + + return ( + + + {`${count}`} + + + ); +}; + +export default Badge; diff --git a/app/components/channel_list/categories/body/channel/channel_list_item.tsx b/app/components/channel_list/categories/body/channel/channel_list_item.tsx index f364bd3ec..475035688 100644 --- a/app/components/channel_list/categories/body/channel/channel_list_item.tsx +++ b/app/components/channel_list/categories/body/channel/channel_list_item.tsx @@ -15,6 +15,8 @@ import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; +import Badge from './badge'; + import type ChannelModel from '@typings/database/models/servers/channel'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; @@ -65,7 +67,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.isUnread || myChannel.mentionsCount > 0); + const bright = !isMuted && (isActive || myChannel.isUnread || myChannel.mentionsCount > 0); const sharedValue = useSharedValue(collapsed && !bright); @@ -117,6 +119,7 @@ const ChannelListItem = ({channel, isActive, isOwnDirectMessage, isMuted, myChan shared={channel.shared} size={24} type={channel.type} + isMuted={isMuted} /> {displayName} + {myChannel.mentionsCount > 0 && + + } From 54218d8f3db57198fd5552827932b49d9088fec5 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Wed, 23 Mar 2022 10:06:54 -0300 Subject: [PATCH 13/23] Use new observables approach --- app/screens/reactions/index.ts | 10 ++++---- app/screens/reactions/reactions.tsx | 17 ++++++++------ .../reactions/reactors_list/reactor/index.ts | 23 ++++--------------- 3 files changed, 18 insertions(+), 32 deletions(-) diff --git a/app/screens/reactions/index.ts b/app/screens/reactions/index.ts index 0d76d0cd2..0bc13f15c 100644 --- a/app/screens/reactions/index.ts +++ b/app/screens/reactions/index.ts @@ -3,27 +3,25 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; -import {MM_TABLES} from '@constants/database'; +import {observePost} from '@queries/servers/post'; import Reactions from './reactions'; import type {WithDatabaseArgs} from '@typings/database/database'; -import type PostModel from '@typings/database/models/servers/post'; type EnhancedProps = WithDatabaseArgs & { postId: string; } -const {POST} = MM_TABLES.SERVER; - const enhanced = withObservables([], ({postId, database}: EnhancedProps) => { - const post = database.get(POST).findAndObserve(postId); + const post = observePost(database, postId); return { reactions: post.pipe( - switchMap((p) => p.reactions.observe()), + switchMap((p) => (p ? p.reactions.observe() : of$(undefined))), ), }; }); diff --git a/app/screens/reactions/reactions.tsx b/app/screens/reactions/reactions.tsx index 6208e1a1e..fd8685416 100644 --- a/app/screens/reactions/reactions.tsx +++ b/app/screens/reactions/reactions.tsx @@ -15,14 +15,14 @@ import type ReactionModel from '@typings/database/models/servers/reaction'; type Props = { initialEmoji: string; - reactions: ReactionModel[]; + reactions?: ReactionModel[]; } const Reactions = ({initialEmoji, reactions}: Props) => { - const [sortedReactions, setSortedReactions] = useState(Array.from(new Set(reactions.map((r) => getEmojiFirstAlias(r.emojiName))))); + 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) => { + return reactions?.reduce((acc, reaction) => { const emojiAlias = getEmojiFirstAlias(reaction.emojiName); if (acc.has(emojiAlias)) { const rs = acc.get(emojiAlias); @@ -41,6 +41,9 @@ const Reactions = ({initialEmoji, reactions}: Props) => { const renderContent = useCallback(() => { const emojiAlias = sortedReactions[index]; + if (!reactionsByName) { + return null; + } return ( <> @@ -61,11 +64,11 @@ const Reactions = ({initialEmoji, reactions}: Props) => { useEffect(() => { // This helps keep the reactions in the same position at all times until unmounted - const rs = reactions.map((r) => getEmojiFirstAlias(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); - const removed = [...sorted].filter((s) => !rs.includes(s)); + 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]); diff --git a/app/screens/reactions/reactors_list/reactor/index.ts b/app/screens/reactions/reactors_list/reactor/index.ts index 3a3a19056..148b5a56a 100644 --- a/app/screens/reactions/reactors_list/reactor/index.ts +++ b/app/screens/reactions/reactors_list/reactor/index.ts @@ -1,33 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Q} from '@nozbe/watermelondb'; import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import {of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; -import {MM_TABLES} from '@constants/database'; +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'; -import type UserModel from '@typings/database/models/servers/user'; -const {SERVER: {USER}} = MM_TABLES; - -const enhance = withObservables(['reaction'], ({database, reaction}: {reaction: ReactionModel} & WithDatabaseArgs) => { - const user = database.get(USER).query( - Q.where('id', reaction.userId), - Q.take(1), - ).observe().pipe( - switchMap((result) => (result.length ? result[0].observe() : of$(undefined))), - ); - - return { - user, - }; -}); +const enhance = withObservables(['reaction'], ({database, reaction}: {reaction: ReactionModel} & WithDatabaseArgs) => ({ + user: observeUser(database, reaction.userId), +})); export default withDatabase(enhance(Reactor)); From 7f9cd287feca18fcf2ae83c7d35410f514b007a2 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Wed, 23 Mar 2022 11:35:37 -0300 Subject: [PATCH 14/23] feedback review --- app/actions/websocket/reactions.ts | 2 +- .../post_list/post/body/reactions/reaction.tsx | 7 +++++-- app/queries/servers/reactions.ts | 15 --------------- .../reactions/reactors_list/reactor/reactor.tsx | 3 --- types/api/users.d.ts | 1 - 5 files changed, 6 insertions(+), 22 deletions(-) delete mode 100644 app/queries/servers/reactions.ts diff --git a/app/actions/websocket/reactions.ts b/app/actions/websocket/reactions.ts index fe3bc8c5c..d922429b3 100644 --- a/app/actions/websocket/reactions.ts +++ b/app/actions/websocket/reactions.ts @@ -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 { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; diff --git a/app/components/post_list/post/body/reactions/reaction.tsx b/app/components/post_list/post/body/reactions/reaction.tsx index 087c17cef..7527ff0e1 100644 --- a/app/components/post_list/post/body/reactions/reaction.tsx +++ b/app/components/post_list/post/body/reactions/reaction.tsx @@ -18,6 +18,9 @@ type ReactionProps = { theme: Theme; } +const MIN_WIDTH = 50; +const DIGIT_WIDTH = 5; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { count: { @@ -44,7 +47,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { justifyContent: 'center', marginBottom: 12, marginRight: 8, - minWidth: 50, + minWidth: MIN_WIDTH, }, }; }); @@ -53,7 +56,7 @@ const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: Re const styles = getStyleSheet(theme); const digits = String(count).length; const containerStyle = useMemo(() => { - const minWidth = 50 + (digits * 5); + const minWidth = MIN_WIDTH + (digits * DIGIT_WIDTH); return [styles.reaction, (highlight && styles.highlight), {minWidth}]; }, [styles.reaction, highlight, digits]); diff --git a/app/queries/servers/reactions.ts b/app/queries/servers/reactions.ts deleted file mode 100644 index 8b7176618..000000000 --- a/app/queries/servers/reactions.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Database, Q} from '@nozbe/watermelondb'; - -import {MM_TABLES} from '@constants/database'; -const {SERVER: {REACTION}} = MM_TABLES; - -export const queryReaction = (database: Database, emojiName: string, postId: string, userId: string) => { - return database.get(REACTION).query( - Q.where('emoji_name', emojiName), - Q.where('post_id', postId), - Q.where('user_id', userId), - ); -}; diff --git a/app/screens/reactions/reactors_list/reactor/reactor.tsx b/app/screens/reactions/reactors_list/reactor/reactor.tsx index ab59b878e..80518f771 100644 --- a/app/screens/reactions/reactors_list/reactor/reactor.tsx +++ b/app/screens/reactions/reactors_list/reactor/reactor.tsx @@ -14,9 +14,6 @@ type Props = { const style = StyleSheet.create({ container: { - paddingVertical: 0, - paddingHorizontal: 0, - paddingTop: 0, marginBottom: 8, }, }); diff --git a/types/api/users.d.ts b/types/api/users.d.ts index 465972da2..09be2f807 100644 --- a/types/api/users.d.ts +++ b/types/api/users.d.ts @@ -43,7 +43,6 @@ type UserProfile = { last_picture_update: number; remote_id?: string; status?: string; - remote_id?: string; }; type UsersState = { From 7382c1f4fc3e914ca684d99873bff26a60251a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Wed, 23 Mar 2022 16:11:58 +0100 Subject: [PATCH 15/23] Address feedback --- app/actions/remote/command.ts | 9 +++++++++ .../slash_suggestion/app_command_parser/mentions.ts | 5 +---- .../autocomplete/slash_suggestion/slash_suggestion.tsx | 8 ++++++++ .../slash_suggestion/slash_suggestion_item.tsx | 7 ++++++- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/actions/remote/command.ts b/app/actions/remote/command.ts index 2610e2f36..14675bdf8 100644 --- a/app/actions/remote/command.ts +++ b/app/actions/remote/command.ts @@ -205,6 +205,10 @@ 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}; @@ -215,6 +219,11 @@ export const fetchSuggestions = async (serverUrl: string, term: string, teamId: 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}; diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts index a1577c5ab..436779d41 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts @@ -73,10 +73,7 @@ export async function getChannelSuggestions(channels?: Channel[]): Promise(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme)); const mounted = useRef(false); + const [noResultsTerm, setNoResultsTerm] = useState(null); const [dataSource, setDataSource] = useState(emptySuggestionList); const [commands, setCommands] = useState(); @@ -113,6 +114,7 @@ const SlashSuggestion = ({ updateSuggestions(emptySuggestionList); } else if (res.suggestions.length === 0) { updateSuggestions(emptySuggestionList); + setNoResultsTerm(term); } else { updateSuggestions(res.suggestions); } @@ -182,6 +184,7 @@ const SlashSuggestion = ({ useEffect(() => { if (value[0] !== '/') { runFetch.cancel(); + setNoResultsTerm(null); updateSuggestions(emptySuggestionList); return; } @@ -200,6 +203,11 @@ const SlashSuggestion = ({ if (value.indexOf(' ') === -1) { runFetch.cancel(); showBaseCommands(value); + setNoResultsTerm(null); + return; + } + + if (noResultsTerm && value.startsWith(noResultsTerm)) { return; } diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx index bf0c35132..05b508cb5 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx @@ -122,7 +122,12 @@ const SlashSuggestionItem = ({ } else if (icon.startsWith('data:')) { if (icon.startsWith('data:image/svg+xml')) { // TODO: What base64 library should we use? Security implications on doing things like this? - const xml = ''; // base64.decode(icon.substring('data:image/svg+xml;base64,'.length)); + let xml = ''; + try { + xml = Buffer.from(icon.substring('data:image/svg+xml;base64,'.length), 'base64').toString(); + } catch { + // Do nothing + } image = ( Date: Wed, 23 Mar 2022 17:37:20 +0100 Subject: [PATCH 16/23] Remove bang image and use compass icon --- .../slash_suggestion/slash_suggestion_item.tsx | 11 ++++------- .../images/autocomplete/slash_command_error.png | Bin 288 -> 0 bytes .../autocomplete/slash_command_error@2x.png | Bin 419 -> 0 bytes .../autocomplete/slash_command_error@3x.png | Bin 625 -> 0 bytes 4 files changed, 4 insertions(+), 7 deletions(-) delete mode 100644 assets/base/images/autocomplete/slash_command_error.png delete mode 100644 assets/base/images/autocomplete/slash_command_error@2x.png delete mode 100644 assets/base/images/autocomplete/slash_command_error@3x.png diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx index 05b508cb5..153960301 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx @@ -7,13 +7,13 @@ 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 bangIcon = require('@assets/images/autocomplete/slash_command_error.png'); const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { return { @@ -105,11 +105,9 @@ const SlashSuggestionItem = ({ ); if (icon === COMMAND_SUGGESTION_ERROR) { image = ( - ); } else if (icon.startsWith('http')) { @@ -121,7 +119,6 @@ const SlashSuggestionItem = ({ ); } else if (icon.startsWith('data:')) { if (icon.startsWith('data:image/svg+xml')) { - // TODO: What base64 library should we use? Security implications on doing things like this? let xml = ''; try { xml = Buffer.from(icon.substring('data:image/svg+xml;base64,'.length), 'base64').toString(); diff --git a/assets/base/images/autocomplete/slash_command_error.png b/assets/base/images/autocomplete/slash_command_error.png deleted file mode 100644 index c1dd93d83cb146031fdffd376beed8c7dc97463e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 288 zcmeAS@N?(olHy`uVBq!ia0vp^AhsX}8<2dWZ2J^QaTa()7Bet#3xhBt!>l*8o|0J>k`CC0*978G?_f9nAJES1d`u|bO^0@p4Ehc6&jc`cJXW z_vy~|9~_VJvw7&{_)GD+-dgJQ{`7Y`3@( z1Jm{s0OJ-j@|I&3OW7_9>~*+~=^Z`_NJPJoob8SR9@;NwyG~cR3A-K4{E1xjqkPL3 z4HxDn`0MoEIrsQ8T()hy5Cg~Kr)K~+`IGWCpMT7_9QGX6b$t`UI=P9DU7rlBs_GV4 zRd*)?CBLpDP^tHoa}c~=y!}?(yCw;C5%u zyI8>2ksao@GwgosF2KG?Py-u{+eZLXain|g^`0MqAPGcit=Fy?AC@OIM86 z;G7&AthI51vF75zLI_H$36o=jY1NcaaU5%BHB##0D?U1MR})3%4TvIx!%z~oiY&g> z0BcuLgVQTxak=;Ca5yX;j7it_RZCydEN~*bZEEtuVuNkAX^bGZnGM$ZpIVLR%;ZYx z^1wpK%yw!m%WJjGHYtB1xxqGVHt*y^02_>cZAmW6t2hiZEskwgos~&DDrl~ZL+LZYxpzn zT)Ef~X!rYc7a@Za&1TcF@1L*m4pqSkoTNk; Date: Wed, 23 Mar 2022 09:56:12 -0700 Subject: [PATCH 17/23] Added test for MM-T4691_6 --- app/screens/server/index.tsx | 1 - detox/e2e/support/ui/screen/edit_server.ts | 2 + detox/e2e/support/ui/screen/server.ts | 2 + .../e2e/test/server_login/server_list.e2e.ts | 49 ++++++++++++++----- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index 4fdd26a3f..9d985bd7a 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -319,7 +319,6 @@ const Server = ({ { const serverOneDisplayName = 'Server 1'; const serverTwoDisplayName = 'Server 2'; const serverThreeDisplayName = 'Server 3'; - let serverOneUser; - let serverTwoUser; - let serverThreeUser; + let serverOneUser: any; + let serverTwoUser: any; + let serverThreeUser: any; beforeAll(async () => { // # Log in to the first server @@ -115,7 +115,7 @@ describe('Server Login - Server List', () => { await ChannelListScreen.toBeVisible(); await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverOneDisplayName); - // # Tap on third server + // # Open server list screen and tap on third server await ServerListScreen.open(); await ServerListScreen.getServerItemInactive(serverThreeDisplayName).tap(); @@ -123,7 +123,7 @@ describe('Server Login - Server List', () => { await ChannelListScreen.toBeVisible(); await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverThreeDisplayName); - // # Go back to first server + // # Open server list screen and go back to first server await ServerListScreen.open(); await ServerListScreen.getServerItemInactive(serverOneDisplayName).tap(); }); @@ -133,7 +133,7 @@ describe('Server Login - Server List', () => { await ChannelListScreen.toBeVisible(); await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverOneDisplayName); - // # Swipe left on first server and tap on edit option + // # 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(); @@ -173,7 +173,7 @@ describe('Server Login - Server List', () => { await ChannelListScreen.toBeVisible(); await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverOneDisplayName); - // # Swipe left on first server and tap on remove option + // # 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(); @@ -190,8 +190,6 @@ describe('Server Login - Server List', () => { await expect(ServerListScreen.getServerItemInactive(serverOneDisplayName)).not.toExist(); // # Add first server back to the list and log in to the first server - await User.apiAdminLogin(siteOneUrl); - ({user: serverOneUser} = await Setup.apiInit(siteOneUrl)); await ServerListScreen.addServerButton.tap(); await expect(ServerScreen.headerTitleAddServer).toBeVisible(); await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName); @@ -203,7 +201,7 @@ describe('Server Login - Server List', () => { await ChannelListScreen.toBeVisible(); await expect(ChannelListScreen.headerServerDisplayName).toHaveText(serverOneDisplayName); - // # Swipe left on first server and tap on logout option + // # 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(); @@ -221,10 +219,37 @@ describe('Server Login - Server List', () => { // # Log back in to first server await ServerListScreen.getServerItemLoginOption(serverOneDisplayName).tap(); - await User.apiAdminLogin(siteOneUrl); - ({user: serverOneUser} = await Setup.apiInit(siteOneUrl)); 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(); + }); }); From f08ccb2d0cbc5c5b25293c9cde9d93932ac9d57c Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Wed, 23 Mar 2022 15:16:14 -0300 Subject: [PATCH 18/23] feedback review --- app/actions/remote/reactions.ts | 2 +- app/components/post_list/post/body/reactions/reactions.tsx | 2 +- app/screens/reactions/emoji_bar/index.tsx | 2 +- app/screens/reactions/emoji_bar/item.tsx | 2 +- app/screens/reactions/reactors_list/index.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/actions/remote/reactions.ts b/app/actions/remote/reactions.ts index 4b0b481e1..30876d572 100644 --- a/app/actions/remote/reactions.ts +++ b/app/actions/remote/reactions.ts @@ -63,7 +63,7 @@ export const addReaction = async (serverUrl: string, postId: string, emojiName: user_id: currentUserId, post_id: postId, emoji_name: emojiAlias, - create_at: Date.now(), + create_at: 0, } as Reaction, }; } catch (error) { diff --git a/app/components/post_list/post/body/reactions/reactions.tsx b/app/components/post_list/post/body/reactions/reactions.tsx index ff63db352..8abeb0cc7 100644 --- a/app/components/post_list/post/body/reactions/reactions.tsx +++ b/app/components/post_list/post/body/reactions/reactions.tsx @@ -149,7 +149,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, showModalOverCurrentContext(screen, passProps); } } - }, [intl, postId, theme]); + }, [intl, isTablet, postId, theme]); let addMoreReactions = null; const {reactionsByName, highlightedReactions} = buildReactionsMap(); diff --git a/app/screens/reactions/emoji_bar/index.tsx b/app/screens/reactions/emoji_bar/index.tsx index 092dbbad0..56e639f1d 100644 --- a/app/screens/reactions/emoji_bar/index.tsx +++ b/app/screens/reactions/emoji_bar/index.tsx @@ -60,7 +60,7 @@ const EmojiBar = ({emojiSelected, reactionsByName, setIndex, sortedReactions}: P onPress={onPress} /> ); - }, [sortedReactions, emojiSelected, reactionsByName]); + }, [onPress, emojiSelected, reactionsByName]); useEffect(() => { const t = setTimeout(() => { diff --git a/app/screens/reactions/emoji_bar/item.tsx b/app/screens/reactions/emoji_bar/item.tsx index 3464b4e3b..ecd2467eb 100644 --- a/app/screens/reactions/emoji_bar/item.tsx +++ b/app/screens/reactions/emoji_bar/item.tsx @@ -51,7 +51,7 @@ const Reaction = ({count, emojiName, highlight, onPress}: ReactionProps) => { const handlePress = useCallback(() => { onPress(emojiName); - }, [highlight]); + }, [onPress, emojiName]); return ( { const serverUrl = useServerUrl(); const [enabled, setEnabled] = useState(false); - const listRef = useRef(null); const [direction, setDirection] = useState<'down' | 'up'>('down'); + const listRef = useRef(null); const prevOffset = useRef(0); const panResponder = useRef(PanResponder.create({ onMoveShouldSetPanResponderCapture: (evt, g) => { From ab45851e192f6de7377e4d7d821acf4df65d2292 Mon Sep 17 00:00:00 2001 From: Shaz MJ Date: Thu, 24 Mar 2022 08:19:43 +1100 Subject: [PATCH 19/23] Uses existing badge --- .../__snapshots__/category_body.test.tsx.snap | 6 +- .../categories/body/category_body.tsx | 2 +- .../channel/__snapshots__/badge.test.tsx.snap | 38 ------------ .../channel_list_item.test.tsx.snap | 6 +- .../categories/body/channel/badge.test.tsx | 20 ------ .../categories/body/channel/badge.tsx | 62 ------------------- .../body/channel/channel_list_item.tsx | 31 ++++++---- 7 files changed, 28 insertions(+), 137 deletions(-) delete mode 100644 app/components/channel_list/categories/body/channel/__snapshots__/badge.test.tsx.snap delete mode 100644 app/components/channel_list/categories/body/channel/badge.test.tsx delete mode 100644 app/components/channel_list/categories/body/channel/badge.tsx diff --git a/app/components/channel_list/categories/body/__snapshots__/category_body.test.tsx.snap b/app/components/channel_list/categories/body/__snapshots__/category_body.test.tsx.snap index f8bfadc08..7202ed8da 100644 --- a/app/components/channel_list/categories/body/__snapshots__/category_body.test.tsx.snap +++ b/app/components/channel_list/categories/body/__snapshots__/category_body.test.tsx.snap @@ -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 { diff --git a/app/components/channel_list/categories/body/category_body.tsx b/app/components/channel_list/categories/body/category_body.tsx index c27f2ac00..2961cf21f 100644 --- a/app/components/channel_list/categories/body/category_body.tsx +++ b/app/components/channel_list/categories/body/category_body.tsx @@ -41,7 +41,7 @@ const CategoryBody = ({currentChannelId, sortedIds, category, hiddenChannelIds, collapsed={category.collapsed} /> ); - }, [currentChannelId]); + }, [currentChannelId, category.collapsed]); return ( - - 10 - - -`; diff --git a/app/components/channel_list/categories/body/channel/__snapshots__/channel_list_item.test.tsx.snap b/app/components/channel_list/categories/body/channel/__snapshots__/channel_list_item.test.tsx.snap index 9f969d335..ddfdff33a 100644 --- a/app/components/channel_list/categories/body/channel/__snapshots__/channel_list_item.test.tsx.snap +++ b/app/components/channel_list/categories/body/channel/__snapshots__/channel_list_item.test.tsx.snap @@ -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 diff --git a/app/components/channel_list/categories/body/channel/badge.test.tsx b/app/components/channel_list/categories/body/channel/badge.test.tsx deleted file mode 100644 index 0696f225e..000000000 --- a/app/components/channel_list/categories/body/channel/badge.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import {renderWithIntlAndTheme} from '@test/intl-test-helper'; - -import Badge from './badge'; - -describe('components/channel_list/categories/body/channel/badge', () => { - it('should match snapshot', () => { - const wrapper = renderWithIntlAndTheme( - , - ); - expect(wrapper.toJSON()).toMatchSnapshot(); - }); -}); diff --git a/app/components/channel_list/categories/body/channel/badge.tsx b/app/components/channel_list/categories/body/channel/badge.tsx deleted file mode 100644 index 7a7270f38..000000000 --- a/app/components/channel_list/categories/body/channel/badge.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useMemo} from 'react'; -import {Text, View} from 'react-native'; - -import {useTheme} from '@context/theme'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; - -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ - badge: { - backgroundColor: theme.mentionBg, - borderRadius: 10, - height: 20, - marginTop: 5, - paddingHorizontal: 8, - alignItems: 'center', - justifyContent: 'center', - textAlignVertical: 'center', - }, - text: { - color: theme.mentionColor, - ...typography('Body', 75), - }, - mutedBadge: { - backgroundColor: changeOpacity(theme.mentionBg, 0.4), - }, - mutedText: { - color: changeOpacity(theme.mentionColor, 0.4), - }, -})); - -const Badge = ({count, muted}: {count: number; muted: boolean}) => { - const theme = useTheme(); - const styles = getStyleSheet(theme); - - const viewStyle = useMemo(() => [ - styles.badge, - muted && styles.mutedBadge, - ], [muted]); - - const textStyle = useMemo(() => [ - styles.text, - - muted && styles.mutedText, - ], [muted]); - - if (!count) { - return null; - } - - return ( - - - {`${count}`} - - - ); -}; - -export default Badge; diff --git a/app/components/channel_list/categories/body/channel/channel_list_item.tsx b/app/components/channel_list/categories/body/channel/channel_list_item.tsx index 475035688..1e458be25 100644 --- a/app/components/channel_list/categories/body/channel/channel_list_item.tsx +++ b/app/components/channel_list/categories/body/channel/channel_list_item.tsx @@ -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'; @@ -15,17 +16,15 @@ import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; -import Badge from './badge'; - import type ChannelModel from '@typings/database/models/servers/channel'; 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, @@ -44,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({ @@ -77,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)}), }; @@ -87,7 +97,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]); @@ -128,13 +137,11 @@ const ChannelListItem = ({channel, isActive, isOwnDirectMessage, isMuted, myChan > {displayName} - {myChannel.mentionsCount > 0 && - - } - + 0} + value={myChannel.mentionsCount} + style={[styles.badge, isMuted && styles.mutedBadge]} + /> From c0b265132abdcc7951e4be04c830902e384b44a3 Mon Sep 17 00:00:00 2001 From: Shaz MJ Date: Thu, 24 Mar 2022 08:29:35 +1100 Subject: [PATCH 20/23] Removes dupes --- app/queries/servers/channel.ts | 4 ---- app/queries/servers/preference.ts | 14 -------------- 2 files changed, 18 deletions(-) diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 116afdf50..4bce45aeb 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -307,7 +307,3 @@ export const queryMyChannelSettingsByIds = (database: Database, ids: string[]) = export const queryChannelsByNames = (database: Database, names: string[]) => { return database.get(CHANNEL).query(Q.where('name', Q.oneOf(names))); }; - -export const queryChannelsByNames = (database: Database, names: string[]) => { - return database.get(CHANNEL).query(Q.where('name', Q.oneOf(names))); -}; diff --git a/app/queries/servers/preference.ts b/app/queries/servers/preference.ts index c68657b37..93aa39f41 100644 --- a/app/queries/servers/preference.ts +++ b/app/queries/servers/preference.ts @@ -68,17 +68,3 @@ export const deletePreferences = async (database: ServerDatabase, preferences: P return false; } }; - -export const queryPreferencesByCategory = (database: Database, category: string, name?: string, value?: string) => { - const clauses = [Q.where('category', category)]; - - if (typeof name === 'string') { - clauses.push(Q.where('name', name)); - } - - if (typeof value === 'string') { - clauses.push(Q.where('value', value)); - } - - return database.get(PREFERENCE).query(...clauses); -}; From aa4776a2604492c4be47f5f93b85e4aa4b9ddb44 Mon Sep 17 00:00:00 2001 From: Anurag Shivarathri Date: Thu, 24 Mar 2022 18:29:10 +0530 Subject: [PATCH 21/23] Replies count query (#6088) --- app/components/post_list/post/header/index.ts | 4 ++-- app/components/post_list/thread_overview/index.ts | 4 ++-- app/queries/servers/post.ts | 8 ++++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/components/post_list/post/header/index.ts b/app/components/post_list/post/header/index.ts index f11c750ea..0178f49a5 100644 --- a/app/components/post_list/post/header/index.ts +++ b/app/components/post_list/post/header/index.ts @@ -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(); diff --git a/app/components/post_list/thread_overview/index.ts b/app/components/post_list/thread_overview/index.ts index 42b771670..01c33a8c7 100644 --- a/app/components/post_list/thread_overview/index.ts +++ b/app/components/post_list/thread_overview/index.ts @@ -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), }; }); diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index 190388cea..95e2f3adb 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -75,6 +75,14 @@ export const queryPostsInThread = (database: Database, rootId: string, sorted = return database.get(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) { From 2393151ff024eebba80bafe60e572b461c63e5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Thu, 24 Mar 2022 18:40:38 +0100 Subject: [PATCH 22/23] Add integrations manager, use base-64 to handle svgs and minor improvement and fixes in the components --- .../app_command_parser/app_command_parser.ts | 33 ++----- .../app_slash_suggestion.tsx | 28 +++--- .../app_slash_suggestion/index.ts | 4 +- .../autocomplete/slash_suggestion/index.ts | 4 +- .../slash_suggestion/slash_suggestion.tsx | 15 ++-- .../slash_suggestion_item.tsx | 39 +++++---- app/init/integrations_manager.ts | 86 +++++++++++++++++++ package-lock.json | 24 ++++++ package.json | 2 + 9 files changed, 169 insertions(+), 66 deletions(-) create mode 100644 app/init/integrations_manager.ts diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts index 94a6656cd..4f69940b7 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts @@ -9,6 +9,7 @@ import {fetchChannelById, fetchChannelByName, searchChannels} from '@actions/rem import {fetchUsersByIds, fetchUsersByUsernames, searchUsers} from '@actions/remote/user'; import {AppCallResponseTypes, AppCallTypes, AppFieldTypes, COMMAND_SUGGESTION_ERROR} from '@constants/apps'; import DatabaseManager from '@database/manager'; +import IntegrationsManager from '@init/integrations_manager'; import {getChannelById, queryChannelsByNames} from '@queries/servers/channel'; import {getCurrentTeamId} from '@queries/servers/system'; import {getUserById, queryUsersByUsername} from '@queries/servers/user'; @@ -48,26 +49,6 @@ interface Intl { formatMessage(config: {id: string; defaultMessage: string}, values?: {[name: string]: any}): string; } -// TODO: Implemnet app bindings -const getCommandBindings = () => { - return []; -}; -const getRHSCommandBindings = () => { - return []; -}; -const getAppRHSCommandForm = (key: string) => {// eslint-disable-line @typescript-eslint/no-unused-vars - return undefined; -}; -const getAppCommandForm = (key: string) => {// eslint-disable-line @typescript-eslint/no-unused-vars - return undefined; -}; -const setAppRHSCommandForm = (key: string, form: AppForm) => {// eslint-disable-line @typescript-eslint/no-unused-vars - return undefined; -}; -const setAppCommandForm = (key: string, form: AppForm) => {// eslint-disable-line @typescript-eslint/no-unused-vars - return undefined; -}; - // Common dependencies with Webapp. Due to the big change of removing redux, we may have to rethink how to deal with this. const getExecuteSuggestion = (parsed: ParsedCommand) => null; // eslint-disable-line @typescript-eslint/no-unused-vars export const EXECUTE_CURRENT_COMMAND_ITEM_ID = '_execute_current_command'; @@ -961,10 +942,11 @@ export class AppCommandParser { // getCommandBindings returns the commands in the redux store. // They are grouped by app id since each app has one base command private getCommandBindings = (): AppBinding[] => { + const manager = IntegrationsManager.getManager(this.serverUrl); if (this.rootPostID) { - return getRHSCommandBindings(); + return manager.getRHSCommandBindings(); } - return getCommandBindings(); + return manager.getCommandBindings(); }; // getChannel gets the channel in which the user is typing the command @@ -1067,9 +1049,10 @@ export class AppCommandParser { }; public getForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => { + const manager = IntegrationsManager.getManager(this.serverUrl); const rootID = this.rootPostID || ''; const key = `${this.channelID}-${rootID}-${location}`; - const form = this.rootPostID ? getAppRHSCommandForm(key) : getAppCommandForm(key); + const form = this.rootPostID ? manager.getAppRHSCommandForm(key) : manager.getAppCommandForm(key); if (form) { return {form}; } @@ -1077,9 +1060,9 @@ export class AppCommandParser { const fetched = await this.fetchForm(binding); if (fetched?.form) { if (this.rootPostID) { - setAppRHSCommandForm(key, fetched.form); + manager.setAppRHSCommandForm(key, fetched.form); } else { - setAppCommandForm(key, fetched.form); + manager.setAppCommandForm(key, fetched.form); } } return fetched; diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx index eb755caac..920d846f4 100644 --- a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx +++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {debounce} from 'lodash'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import { FlatList, @@ -70,6 +70,8 @@ const AppSlashSuggestion = ({ 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); @@ -84,7 +86,7 @@ const AppSlashSuggestion = ({ onShowingChange(Boolean(matches.length)); }; - const completeSuggestion = (command: string) => { + 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 @@ -103,21 +105,15 @@ const AppSlashSuggestion = ({ updateValue(completedDraft.replace(`//${command} `, `/${command} `)); }); } - }; + }, [serverUrl, updateValue]); - const completeUserSuggestion = (base: string): (username: string) => void => { + const completeIgnoringSuggestion = useCallback((base: string): (toIgnore: string) => void => { return () => { completeSuggestion(base); }; - }; + }, [completeSuggestion]); - const completeChannelMention = (base: string): (channelName: string) => void => { - return () => { - completeSuggestion(base); - }; - }; - - const renderItem = ({item}: {item: ExtendedAutocompleteSuggestion}) => { + const renderItem = useCallback(({item}: {item: ExtendedAutocompleteSuggestion}) => { switch (item.type) { case COMMAND_SUGGESTION_USER: if (!item.item) { @@ -126,7 +122,7 @@ const AppSlashSuggestion = ({ return ( ); @@ -137,7 +133,7 @@ const AppSlashSuggestion = ({ return ( ); @@ -153,7 +149,7 @@ const AppSlashSuggestion = ({ /> ); } - }; + }, [completeSuggestion, completeIgnoringSuggestion]); const isAppCommand = (pretext: string, channelID: string, teamID = '', rootID?: string) => { appCommandParser.current.setChannelContext(channelID, teamID, rootID); @@ -199,7 +195,7 @@ const AppSlashSuggestion = ({ ({ - currentTeamId: observeCurrentChannelId(database), + currentTeamId: observeCurrentTeamId(database), isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), })); diff --git a/app/components/autocomplete/slash_suggestion/index.ts b/app/components/autocomplete/slash_suggestion/index.ts index bad9e4ee7..2a65f3979 100644 --- a/app/components/autocomplete/slash_suggestion/index.ts +++ b/app/components/autocomplete/slash_suggestion/index.ts @@ -4,14 +4,14 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import {observeConfigBooleanValue, observeCurrentChannelId} from '@queries/servers/system'; +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: observeCurrentChannelId(database), + currentTeamId: observeCurrentTeamId(database), isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), })); diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx index 5dd4e0ee8..5b96de6a2 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx @@ -9,7 +9,8 @@ import { Platform, } from 'react-native'; -import {fetchCommands, fetchSuggestions} from '@actions/remote/command'; +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'; @@ -99,6 +100,8 @@ const SlashSuggestion = ({ 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)); @@ -190,11 +193,11 @@ const SlashSuggestion = ({ } if (!commands) { - fetchCommands(serverUrl, currentTeamId).then((res) => { - if (res.error) { - setCommands(emptyCommandList); + IntegrationsManager.getManager(serverUrl).fetchCommands(currentTeamId).then((res) => { + if (res.length) { + setCommands(res.filter(commandFilter)); } else { - setCommands(res.commands.filter(commandFilter)); + setCommands(emptyCommandList); } }); return; @@ -231,7 +234,7 @@ const SlashSuggestion = ({ { + 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) { @@ -113,7 +122,7 @@ const SlashSuggestionItem = ({ } else if (icon.startsWith('http')) { image = ( ); @@ -121,21 +130,21 @@ const SlashSuggestionItem = ({ if (icon.startsWith('data:image/svg+xml')) { let xml = ''; try { - xml = Buffer.from(icon.substring('data:image/svg+xml;base64,'.length), 'base64').toString(); - } catch { + xml = base64.decode(icon.substring('data:image/svg+xml;base64,'.length)); + image = ( + + ); + } catch (error) { // Do nothing } - image = ( - - ); } else { image = ( ); @@ -145,7 +154,7 @@ const SlashSuggestionItem = ({ return ( diff --git a/app/init/integrations_manager.ts b/app/init/integrations_manager.ts new file mode 100644 index 000000000..86859775d --- /dev/null +++ b/app/init/integrations_manager.ts @@ -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(); diff --git a/package-lock.json b/package-lock.json index d6f4a29f4..6a0df0afc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,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 +110,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", @@ -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", @@ -28525,6 +28538,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 +30070,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", diff --git a/package.json b/package.json index d9e1e2851..bf630838a 100644 --- a/package.json +++ b/package.json @@ -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", From cc1c112028953761c9ccef1520f718e46d2fd07c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Thu, 24 Mar 2022 18:47:16 +0100 Subject: [PATCH 23/23] Nit --- .../app_slash_suggestion/app_slash_suggestion.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx index 920d846f4..be7a6c902 100644 --- a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx +++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx @@ -4,10 +4,7 @@ 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 {FlatList, Platform} from 'react-native'; import AtMentionItem from '@components/autocomplete/at_mention_item'; import ChannelMentionItem from '@components/autocomplete/channel_mention_item';