Skip to content

Commit

Permalink
Restore out of channel and some cleanup (#8050)
Browse files Browse the repository at this point in the history
* Restore out of channel and some cleanup

* Fix i18n
  • Loading branch information
larkox authored Jul 9, 2024
1 parent 1013509 commit f3de444
Show file tree
Hide file tree
Showing 5 changed files with 282 additions and 229 deletions.
249 changes: 20 additions & 229 deletions app/components/autocomplete/at_mention/at_mention.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,194 +3,23 @@

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';
import AutocompleteSectionHeader from '@components/autocomplete/autocomplete_section_header';
import SpecialMentionItem from '@components/autocomplete/special_mention_item';
import {AT_MENTION_REGEX, AT_MENTION_SEARCH_REGEX} from '@constants/autocomplete';
import {useServerUrl} from '@context/server';
import 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<SectionListData<UserProfile|UserModel|GroupModel|SpecialMention>>

const getMatchTermForAtMention = (() => {
let lastMatchTerm: string | null = null;
let lastValue: string;
let lastIsSearch: boolean;
return (value: string, isSearch: boolean) => {
if (value !== lastValue || isSearch !== lastIsSearch) {
const regex = isSearch ? AT_MENTION_SEARCH_REGEX : AT_MENTION_REGEX;
let term = value.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<UserModel | UserProfile>, 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<UserProfile | UserModel>, users: Array<UserProfile | UserModel>, 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;
Expand All @@ -207,19 +36,6 @@ type Props = {
listStyle: StyleProp<ViewStyle>;
}

const emptyUserlList: Array<UserModel | UserProfile> = [];
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,
Expand All @@ -238,7 +54,8 @@ const AtMention = ({
const serverUrl = useServerUrl();

const [sections, setSections] = useState<UserMentionSections>(emptySectionList);
const [users, setUsers] = useState<Array<UserProfile | UserModel>>(emptyUserlList);
const [usersInChannel, setUsersInChannel] = useState<Array<UserProfile | UserModel>>(emptyUserlList);
const [usersOutOfChannel, setUsersOutOfChannel] = useState<Array<UserProfile | UserModel>>(emptyUserlList);
const [groups, setGroups] = useState<GroupModel[]>(emptyGroupList);
const [loading, setLoading] = useState(false);
const [noResultsTerm, setNoResultsTerm] = useState<string|null>(null);
Expand Down Expand Up @@ -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<UserProfile | UserModel> = await getUsersFromDMSorted(database, memberIds);
const sortedMembersId = new Set<string>(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);
Expand Down Expand Up @@ -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;

Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions app/components/autocomplete/at_mention/constants.ts
Original file line number Diff line number Diff line change
@@ -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<UserModel | UserProfile> = [];
export const emptySectionList: UserMentionSections = [];
export const emptyGroupList: GroupModel[] = [];
15 changes: 15 additions & 0 deletions app/components/autocomplete/at_mention/types.ts
Original file line number Diff line number Diff line change
@@ -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<SectionListData<UserProfile|UserModel|GroupModel|SpecialMention>>;
Loading

0 comments on commit f3de444

Please sign in to comment.