From f3de4445c2775eac65105ef3da53bf8b487990c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Tue, 9 Jul 2024 11:31:31 +0200 Subject: [PATCH] Restore out of channel and some cleanup (#8050) * Restore out of channel and some cleanup * Fix i18n --- .../autocomplete/at_mention/at_mention.tsx | 249 ++---------------- .../autocomplete/at_mention/constants.ts | 16 ++ .../autocomplete/at_mention/types.ts | 15 ++ .../autocomplete/at_mention/utils.ts | 230 ++++++++++++++++ assets/base/i18n/en.json | 1 + 5 files changed, 282 insertions(+), 229 deletions(-) create mode 100644 app/components/autocomplete/at_mention/constants.ts create mode 100644 app/components/autocomplete/at_mention/types.ts create mode 100644 app/components/autocomplete/at_mention/utils.ts diff --git a/app/components/autocomplete/at_mention/at_mention.tsx b/app/components/autocomplete/at_mention/at_mention.tsx index 2fb8b2766..40418979a 100644 --- a/app/components/autocomplete/at_mention/at_mention.tsx +++ b/app/components/autocomplete/at_mention/at_mention.tsx @@ -3,9 +3,8 @@ import {debounce} from 'lodash'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {Platform, SectionList, type SectionListData, type SectionListRenderItemInfo, type StyleProp, type ViewStyle} from 'react-native'; +import {Platform, SectionList, type SectionListRenderItemInfo, type StyleProp, type ViewStyle} from 'react-native'; -import {searchGroupsByName, searchGroupsByNameInChannel, searchGroupsByNameInTeam} from '@actions/local/group'; import {searchUsers} from '@actions/remote/user'; import GroupMentionItem from '@components/autocomplete/at_mention_group/at_mention_group'; import AtMentionItem from '@components/autocomplete/at_mention_item'; @@ -13,184 +12,14 @@ import AutocompleteSectionHeader from '@components/autocomplete/autocomplete_sec import SpecialMentionItem from '@components/autocomplete/special_mention_item'; import {AT_MENTION_REGEX, AT_MENTION_SEARCH_REGEX} from '@constants/autocomplete'; import {useServerUrl} from '@context/server'; -import DatabaseManager from '@database/manager'; -import {t} from '@i18n'; -import {queryAllUsers, getUsersFromDMSorted} from '@queries/servers/user'; -import {hasTrailingSpaces} from '@utils/helpers'; +import {SECTION_KEY_GROUPS, SECTION_KEY_SPECIAL, emptyGroupList, emptySectionList, emptyUserlList} from './constants'; +import {checkSpecialMentions, filterResults, getAllUsers, getMatchTermForAtMention, keyExtractor, makeSections, searchGroups, sortReceivedUsers} from './utils'; + +import type {SpecialMention, UserMentionSections} from './types'; import type GroupModel from '@typings/database/models/servers/group'; import type UserModel from '@typings/database/models/servers/user'; -const SECTION_KEY_TEAM_MEMBERS = 'teamMembers'; -const SECTION_KEY_IN_CHANNEL = 'inChannel'; -const SECTION_KEY_SPECIAL = 'special'; -const SECTION_KEY_GROUPS = 'groups'; - -type SpecialMention = { - completeHandle: string; - id: string; - defaultMessage: string; -} - -type UserMentionSections = Array> - -const getMatchTermForAtMention = (() => { - let lastMatchTerm: string | null = null; - let lastValue: string; - let lastIsSearch: boolean; - return (value: string, isSearch: boolean) => { - if (value !== lastValue || isSearch !== lastIsSearch) { - const regex = isSearch ? AT_MENTION_SEARCH_REGEX : AT_MENTION_REGEX; - let term = value.toLowerCase(); - if (term.startsWith('from: @') || term.startsWith('from:@')) { - term = term.replace('@', ''); - } - - const match = term.match(regex); - lastValue = value; - lastIsSearch = isSearch; - if (match) { - lastMatchTerm = (isSearch ? match[1] : match[2]).toLowerCase(); - } else { - lastMatchTerm = null; - } - } - return lastMatchTerm; - }; -})(); - -const getSpecialMentions: () => SpecialMention[] = () => { - return [{ - completeHandle: 'all', - id: t('suggestion.mention.all'), - defaultMessage: 'Notifies everyone in this channel', - }, { - completeHandle: 'channel', - id: t('suggestion.mention.channel'), - defaultMessage: 'Notifies everyone in this channel', - }, { - completeHandle: 'here', - id: t('suggestion.mention.here'), - defaultMessage: 'Notifies everyone online in this channel', - }]; -}; - -const checkSpecialMentions = (term: string) => { - return getSpecialMentions().filter((m) => m.completeHandle.startsWith(term)).length > 0; -}; - -const keyExtractor = (item: UserProfile) => { - return item.id; -}; - -const filterResults = (users: Array, term: string) => { - return users.filter((u) => { - const firstName = ('firstName' in u ? u.firstName : u.first_name).toLowerCase(); - const lastName = ('lastName' in u ? u.lastName : u.last_name).toLowerCase(); - const fullName = `${firstName} ${lastName}`; - return u.username.toLowerCase().includes(term) || - u.nickname.toLowerCase().includes(term) || - fullName.includes(term) || - u.email.toLowerCase().includes(term); - }); -}; - -const makeSections = (teamMembers: Array, users: Array, groups: GroupModel[], showSpecialMentions: boolean, isLocal = false, isSearch = false) => { - const newSections: UserMentionSections = []; - - if (isSearch) { - if (teamMembers.length) { - newSections.push({ - id: t('mobile.suggestion.members'), - defaultMessage: 'Members', - data: teamMembers, - key: SECTION_KEY_TEAM_MEMBERS, - }); - } - } else if (isLocal) { - if (teamMembers.length) { - newSections.push({ - id: t('mobile.suggestion.members'), - defaultMessage: 'Members', - data: teamMembers, - key: SECTION_KEY_TEAM_MEMBERS, - }); - } - - if (groups.length) { - newSections.push({ - id: t('suggestion.mention.groups'), - defaultMessage: 'Group Mentions', - data: groups, - key: SECTION_KEY_GROUPS, - }); - } - - if (showSpecialMentions) { - newSections.push({ - id: t('suggestion.mention.special'), - defaultMessage: 'Special Mentions', - data: getSpecialMentions(), - key: SECTION_KEY_SPECIAL, - }); - } - } else { - if (users.length) { - newSections.push({ - id: t('suggestion.mention.users'), - defaultMessage: 'Users', - data: users, - key: SECTION_KEY_IN_CHANNEL, - }); - } - - if (groups.length) { - newSections.push({ - id: t('suggestion.mention.groups'), - defaultMessage: 'Group Mentions', - data: groups, - key: SECTION_KEY_GROUPS, - }); - } - - if (showSpecialMentions) { - newSections.push({ - id: t('suggestion.mention.special'), - defaultMessage: 'Special Mentions', - data: getSpecialMentions(), - key: SECTION_KEY_SPECIAL, - }); - } - } - return newSections; -}; - -const searchGroups = async (serverUrl: string, matchTerm: string, useGroupMentions: boolean, isChannelConstrained: boolean, isTeamConstrained: boolean, channelId?: string, teamId?: string) => { - try { - if (useGroupMentions && matchTerm && matchTerm !== '') { - let g = emptyGroupList; - - if (isChannelConstrained) { - // If the channel is constrained, we only show groups for that channel - if (channelId) { - g = await searchGroupsByNameInChannel(serverUrl, matchTerm, channelId); - } - } else if (isTeamConstrained) { - // If there is no channel constraint, but a team constraint - only show groups for team - g = await searchGroupsByNameInTeam(serverUrl, matchTerm, teamId!); - } else { - // No constraints? Search all groups - g = await searchGroupsByName(serverUrl, matchTerm || ''); - } - - return g.length ? g : emptyGroupList; - } - return emptyGroupList; - } catch (error) { - return emptyGroupList; - } -}; - type Props = { channelId?: string; teamId: string; @@ -207,19 +36,6 @@ type Props = { listStyle: StyleProp; } -const emptyUserlList: Array = []; -const emptySectionList: UserMentionSections = []; -const emptyGroupList: GroupModel[] = []; - -const getAllUsers = async (serverUrl: string) => { - const database = DatabaseManager.serverDatabases[serverUrl]?.database; - if (!database) { - return []; - } - - return queryAllUsers(database).fetch(); -}; - const AtMention = ({ channelId, teamId, @@ -238,7 +54,8 @@ const AtMention = ({ const serverUrl = useServerUrl(); const [sections, setSections] = useState(emptySectionList); - const [users, setUsers] = useState>(emptyUserlList); + const [usersInChannel, setUsersInChannel] = useState>(emptyUserlList); + const [usersOutOfChannel, setUsersOutOfChannel] = useState>(emptyUserlList); const [groups, setGroups] = useState(emptyGroupList); const [loading, setLoading] = useState(false); const [noResultsTerm, setNoResultsTerm] = useState(null); @@ -278,49 +95,23 @@ const AtMention = ({ const filteredUsers = filterResults(fallbackUsers, term); setFilteredLocalUsers(filteredUsers.length ? filteredUsers : emptyUserlList); } else if (receivedUsers) { - await sortRecievedUsers(sUrl, term, receivedUsers?.users, receivedUsers?.out_of_channel); + const [sortedMembers, sortedOutOfChannel] = await sortReceivedUsers(sUrl, term, receivedUsers?.users, receivedUsers?.out_of_channel); + setUsersInChannel(sortedMembers.length ? sortedMembers : emptyUserlList); + setUsersOutOfChannel(sortedOutOfChannel.length ? sortedOutOfChannel : emptyUserlList); } setLoading(false); }, 200), []); - async function sortRecievedUsers(sUrl: string, term: string, receivedUsers: UserProfile[], outOfChannel: UserProfile[] | undefined) { - const database = DatabaseManager.serverDatabases[sUrl]?.database; - if (!database) { - return; - } - const memberIds = receivedUsers.map((e) => e.id); - const sortedMembers: Array = await getUsersFromDMSorted(database, memberIds); - const sortedMembersId = new Set(sortedMembers.map((e) => e.id)); - - const membersNoDm = receivedUsers.filter((u) => !sortedMembersId.has(u.id)); - sortedMembers.push(...membersNoDm); - - if (outOfChannel?.length) { - const outChannelMemberIds = outOfChannel.map((e) => e.id); - - // This only get us the users we have on the database. - // We need to append those users from which we don't have - // information at the end of the list. - const outSortedMembers = await getUsersFromDMSorted(database, outChannelMemberIds); - const idSet = new Set(outSortedMembers.map((v) => v.id)); - const outRest = outOfChannel.filter((v) => !idSet.has(v.id)); - sortedMembers.push(...outSortedMembers, ...outRest); - } - - if (hasTrailingSpaces(term)) { - const filteredReceivedUsers = filterResults(sortedMembers, term); - const slicedArray = filteredReceivedUsers.slice(0, 20); - setUsers(slicedArray.length ? slicedArray : emptyUserlList); - } else { - const slicedArray = sortedMembers.slice(0, 20); - setUsers(slicedArray.length ? slicedArray : emptyUserlList); - } - } + const teamMembers = useMemo( + () => [...usersInChannel, ...usersOutOfChannel], + [usersInChannel, usersOutOfChannel], + ); const matchTerm = getMatchTermForAtMention(value.substring(0, localCursorPosition), isSearch); const resetState = () => { - setUsers(emptyUserlList); + setUsersInChannel(emptyUserlList); + setUsersOutOfChannel(emptyUserlList); setGroups(emptyGroupList); setFilteredLocalUsers(emptyUserlList); setSections(emptySectionList); @@ -438,12 +229,12 @@ const AtMention = ({ return; } const showSpecialMentions = useChannelMentions && matchTerm != null && checkSpecialMentions(matchTerm); - const buildMemberSection = isSearch || (!channelId && users.length > 0); + const buildMemberSection = isSearch || (!channelId && teamMembers.length > 0); let newSections; if (useLocal) { - newSections = makeSections(filteredLocalUsers, [], groups, showSpecialMentions, true, buildMemberSection); + newSections = makeSections(filteredLocalUsers, [], [], groups, showSpecialMentions, true, buildMemberSection); } else { - newSections = makeSections(users, users, groups, showSpecialMentions, buildMemberSection); + newSections = makeSections(teamMembers, usersInChannel, usersOutOfChannel, groups, showSpecialMentions, buildMemberSection); } const nSections = newSections.length; @@ -456,7 +247,7 @@ const AtMention = ({ } setSections(nSections ? newSections : emptySectionList); onShowingChange(Boolean(nSections)); - }, [!useLocal && users, users, groups, loading, channelId, useLocal && filteredLocalUsers]); + }, [!useLocal && usersInChannel, !useLocal && usersOutOfChannel, groups, loading, channelId, useLocal && filteredLocalUsers]); if (sections.length === 0 || noResultsTerm != null) { // If we are not in an active state or the mention has been completed return null so nothing is rendered diff --git a/app/components/autocomplete/at_mention/constants.ts b/app/components/autocomplete/at_mention/constants.ts new file mode 100644 index 000000000..0e8c61eef --- /dev/null +++ b/app/components/autocomplete/at_mention/constants.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {UserMentionSections} from './types'; +import type GroupModel from '@typings/database/models/servers/group'; +import type UserModel from '@typings/database/models/servers/user'; + +export const SECTION_KEY_TEAM_MEMBERS = 'teamMembers'; +export const SECTION_KEY_IN_CHANNEL = 'inChannel'; +export const SECTION_KEY_OUT_OF_CHANNEL = 'outChannel'; +export const SECTION_KEY_SPECIAL = 'special'; +export const SECTION_KEY_GROUPS = 'groups'; + +export const emptyUserlList: Array = []; +export const emptySectionList: UserMentionSections = []; +export const emptyGroupList: GroupModel[] = []; diff --git a/app/components/autocomplete/at_mention/types.ts b/app/components/autocomplete/at_mention/types.ts new file mode 100644 index 000000000..715e1791f --- /dev/null +++ b/app/components/autocomplete/at_mention/types.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {type SectionListData} from 'react-native'; + +import type GroupModel from '@typings/database/models/servers/group'; +import type UserModel from '@typings/database/models/servers/user'; + +export type SpecialMention = { + completeHandle: string; + id: string; + defaultMessage: string; +} + +export type UserMentionSections = Array>; diff --git a/app/components/autocomplete/at_mention/utils.ts b/app/components/autocomplete/at_mention/utils.ts new file mode 100644 index 000000000..8ddd92b91 --- /dev/null +++ b/app/components/autocomplete/at_mention/utils.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {searchGroupsByName, searchGroupsByNameInChannel, searchGroupsByNameInTeam} from '@actions/local/group'; +import {AT_MENTION_REGEX, AT_MENTION_SEARCH_REGEX} from '@constants/autocomplete'; +import DatabaseManager from '@database/manager'; +import {t} from '@i18n'; +import {getUsersFromDMSorted, queryAllUsers} from '@queries/servers/user'; +import {hasTrailingSpaces} from '@utils/helpers'; + +import {SECTION_KEY_GROUPS, SECTION_KEY_IN_CHANNEL, SECTION_KEY_OUT_OF_CHANNEL, SECTION_KEY_SPECIAL, SECTION_KEY_TEAM_MEMBERS, emptyGroupList} from './constants'; + +import type {SpecialMention, UserMentionSections} from './types'; +import type GroupModel from '@typings/database/models/servers/group'; +import type UserModel from '@typings/database/models/servers/user'; + +export const getMatchTermForAtMention = (() => { + let lastMatchTerm: string | null = null; + let lastValue: string; + let lastIsSearch: boolean; + return (value: string, isSearch: boolean) => { + if (value !== lastValue || isSearch !== lastIsSearch) { + const regex = isSearch ? AT_MENTION_SEARCH_REGEX : AT_MENTION_REGEX; + let term = value.toLowerCase(); + if (term.startsWith('from: @') || term.startsWith('from:@')) { + term = term.replace('@', ''); + } + + const match = term.match(regex); + lastValue = value; + lastIsSearch = isSearch; + if (match) { + lastMatchTerm = (isSearch ? match[1] : match[2]).toLowerCase(); + } else { + lastMatchTerm = null; + } + } + return lastMatchTerm; + }; +})(); + +const getSpecialMentions: () => SpecialMention[] = () => { + return [{ + completeHandle: 'all', + id: t('suggestion.mention.all'), + defaultMessage: 'Notifies everyone in this channel', + }, { + completeHandle: 'channel', + id: t('suggestion.mention.channel'), + defaultMessage: 'Notifies everyone in this channel', + }, { + completeHandle: 'here', + id: t('suggestion.mention.here'), + defaultMessage: 'Notifies everyone online in this channel', + }]; +}; + +export const checkSpecialMentions = (term: string) => { + return getSpecialMentions().filter((m) => m.completeHandle.startsWith(term)).length > 0; +}; + +export const keyExtractor = (item: UserProfile) => { + return item.id; +}; + +const filterAndSlice = (term: string, sortedMembers: Array) => { + if (hasTrailingSpaces(term)) { + const filteredReceivedUsers = filterResults(sortedMembers, term); + return filteredReceivedUsers.slice(0, 20); + } + + return sortedMembers.slice(0, 20); +}; + +export async function sortReceivedUsers(sUrl: string, term: string, receivedUsers: UserProfile[], outOfChannel: UserProfile[] | undefined) { + const database = DatabaseManager.serverDatabases[sUrl]?.database; + if (!database) { + return [[], []]; + } + const memberIds = receivedUsers.map((e) => e.id); + const sortedMembers: Array = await getUsersFromDMSorted(database, memberIds); + const sortedMembersOutOfChannel: Array = []; + const sortedMembersId = new Set(sortedMembers.map((e) => e.id)); + + const membersNoDm = receivedUsers.filter((u) => !sortedMembersId.has(u.id)); + sortedMembers.push(...membersNoDm); + + if (outOfChannel?.length) { + const outChannelMemberIds = outOfChannel.map((e) => e.id); + + // This only get us the users we have on the database. + // We need to append those users from which we don't have + // information at the end of the list. + const outSortedMembers = await getUsersFromDMSorted(database, outChannelMemberIds); + const idSet = new Set(outSortedMembers.map((v) => v.id)); + const outRest = outOfChannel.filter((v) => !idSet.has(v.id)); + sortedMembersOutOfChannel.push(...outSortedMembers, ...outRest); + } + + const filteredMembers = filterAndSlice(term, sortedMembers); + const filteredOutOfChannel = filterAndSlice(term, sortedMembersOutOfChannel); + + return [filteredMembers, filteredOutOfChannel]; +} + +export const filterResults = (users: Array, term: string) => { + return users.filter((u) => { + const firstName = ('firstName' in u ? u.firstName : u.first_name).toLowerCase(); + const lastName = ('lastName' in u ? u.lastName : u.last_name).toLowerCase(); + const fullName = `${firstName} ${lastName}`; + return u.username.toLowerCase().includes(term) || + u.nickname.toLowerCase().includes(term) || + fullName.includes(term) || + u.email.toLowerCase().includes(term); + }); +}; + +export const makeSections = (teamMembers: Array, usersInChannel: Array, usersOutOfChannel: Array, groups: GroupModel[], showSpecialMentions: boolean, isLocal = false, isSearch = false) => { + const newSections: UserMentionSections = []; + + if (isSearch) { + if (teamMembers.length) { + newSections.push({ + id: t('mobile.suggestion.members'), + defaultMessage: 'Members', + data: teamMembers, + key: SECTION_KEY_TEAM_MEMBERS, + }); + } + } else if (isLocal) { + if (teamMembers.length) { + newSections.push({ + id: t('mobile.suggestion.members'), + defaultMessage: 'Members', + data: teamMembers, + key: SECTION_KEY_TEAM_MEMBERS, + }); + } + + if (groups.length) { + newSections.push({ + id: t('suggestion.mention.groups'), + defaultMessage: 'Group Mentions', + data: groups, + key: SECTION_KEY_GROUPS, + }); + } + + if (showSpecialMentions) { + newSections.push({ + id: t('suggestion.mention.special'), + defaultMessage: 'Special Mentions', + data: getSpecialMentions(), + key: SECTION_KEY_SPECIAL, + }); + } + } else { + if (usersInChannel.length) { + newSections.push({ + id: t('suggestion.mention.users'), + defaultMessage: 'Users', + data: usersInChannel, + key: SECTION_KEY_IN_CHANNEL, + }); + } + + if (groups.length) { + newSections.push({ + id: t('suggestion.mention.groups'), + defaultMessage: 'Group Mentions', + data: groups, + key: SECTION_KEY_GROUPS, + }); + } + + if (showSpecialMentions) { + newSections.push({ + id: t('suggestion.mention.special'), + defaultMessage: 'Special Mentions', + data: getSpecialMentions(), + key: SECTION_KEY_SPECIAL, + }); + } + + if (usersOutOfChannel.length) { + newSections.push({ + id: t('suggestion.mention.nonmembers'), + defaultMessage: 'Not in Channel', + data: usersOutOfChannel, + key: SECTION_KEY_OUT_OF_CHANNEL, + }); + } + } + return newSections; +}; + +export const searchGroups = async (serverUrl: string, matchTerm: string, useGroupMentions: boolean, isChannelConstrained: boolean, isTeamConstrained: boolean, channelId?: string, teamId?: string) => { + try { + if (useGroupMentions && matchTerm && matchTerm !== '') { + let g = emptyGroupList; + + if (isChannelConstrained) { + // If the channel is constrained, we only show groups for that channel + if (channelId) { + g = await searchGroupsByNameInChannel(serverUrl, matchTerm, channelId); + } + } else if (isTeamConstrained) { + // If there is no channel constraint, but a team constraint - only show groups for team + g = await searchGroupsByNameInTeam(serverUrl, matchTerm, teamId!); + } else { + // No constraints? Search all groups + g = await searchGroupsByName(serverUrl, matchTerm || ''); + } + + return g.length ? g : emptyGroupList; + } + return emptyGroupList; + } catch (error) { + return emptyGroupList; + } +}; + +export const getAllUsers = async (serverUrl: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return []; + } + + return queryAllUsers(database).fetch(); +}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index f0f1017cf..daaece327 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1080,6 +1080,7 @@ "suggestion.mention.groups": "Group Mentions", "suggestion.mention.here": "Notifies everyone online in this channel", "suggestion.mention.morechannels": "Other Channels", + "suggestion.mention.nonmembers": "Not in Channel", "suggestion.mention.special": "Special Mentions", "suggestion.mention.users": "Users", "suggestion.search.direct": "Direct Messages",