From f8140f211754b9ce166268d69450d8e99fad854a Mon Sep 17 00:00:00 2001 From: Shaz MJ Date: Wed, 29 Jun 2022 07:47:37 +1000 Subject: [PATCH] [Gekidou] Groups mentions / highlights in messages (posts) (#6338) * WIP * Actions updated to fetch remote first, and local on error * Groups fetch and save * PR Feedback: prepare vs store and undefined fix * Forgot to add file * Groups Mention WIP * Groups highlight! * Merge, PR Feedback * PR Feedback * PR Feedback: Try/Catch blocks * PR Feedback --- .gitignore | 2 +- app/actions/local/group.ts | 46 +++++++++------ app/actions/remote/groups.ts | 52 ++++++++++------- app/actions/remote/user.ts | 51 ++++++++++++++++- .../markdown/at_mention/at_mention.tsx | 57 ++++++++++++++++++- app/components/markdown/at_mention/index.ts | 2 + app/queries/servers/group.ts | 6 ++ app/utils/markdown/index.ts | 1 - 8 files changed, 174 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index a9f0ffe92..5add52608 100644 --- a/.gitignore +++ b/.gitignore @@ -107,7 +107,7 @@ detox/detox_pixel_4_xl_api_30 #editor-settings .vscode .scannerwork +launch.json # Notice.txt generation !build/notice-file - diff --git a/app/actions/local/group.ts b/app/actions/local/group.ts index 3d4f42272..fc1e3cfef 100644 --- a/app/actions/local/group.ts +++ b/app/actions/local/group.ts @@ -9,9 +9,13 @@ import {logError} from '@utils/log'; import type GroupModel from '@typings/database/models/servers/group'; export const searchGroupsByName = async (serverUrl: string, name: string): Promise => { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - throw new Error(`${serverUrl} operator not found`); + let database; + + try { + database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database; + } catch (e) { + logError('searchGroupsByName - DB Error', e); + return []; } try { @@ -23,14 +27,19 @@ export const searchGroupsByName = async (serverUrl: string, name: string): Promi throw groups.error; } catch (e) { logError('searchGroupsByName - ERROR', e); - return queryGroupsByName(operator.database, name).fetch(); + return queryGroupsByName(database, name).fetch(); } }; export const searchGroupsByNameInTeam = async (serverUrl: string, name: string, teamId: string): Promise => { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - throw new Error(`${serverUrl} operator not found`); + let database; + + try { + database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database; + } catch (e) { + // eslint-disable-next-line no-console + console.log('searchGroupsByNameInTeam - DB Error', e); + return []; } try { @@ -42,14 +51,19 @@ export const searchGroupsByNameInTeam = async (serverUrl: string, name: string, throw groups.error; } catch (e) { logError('searchGroupsByNameInTeam - ERROR', e); - return queryGroupsByNameInTeam(operator.database, name, teamId).fetch(); + return queryGroupsByNameInTeam(database, name, teamId).fetch(); } }; export const searchGroupsByNameInChannel = async (serverUrl: string, name: string, channelId: string): Promise => { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - throw new Error(`${serverUrl} operator not found`); + let database; + + try { + database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database; + } catch (e) { + // eslint-disable-next-line no-console + console.log('searchGroupsByNameInChannel - DB Error', e); + return []; } try { @@ -61,7 +75,7 @@ export const searchGroupsByNameInChannel = async (serverUrl: string, name: strin throw groups.error; } catch (e) { logError('searchGroupsByNameInChannel - ERROR', e); - return queryGroupsByNameInChannel(operator.database, name, channelId).fetch(); + return queryGroupsByNameInChannel(database, name, channelId).fetch(); } }; @@ -70,15 +84,11 @@ export const searchGroupsByNameInChannel = async (serverUrl: string, name: strin * * @param serverUrl string - The Server URL * @param groups Group[] - The groups fetched from the API - * @param prepareRecordsOnly boolean - Wether to only prepare records without saving */ export const storeGroups = async (serverUrl: string, groups: Group[]) => { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - throw new Error(`${serverUrl} operator not found`); - } - try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const preparedGroups = await prepareGroups(operator, groups); if (preparedGroups.length) { diff --git a/app/actions/remote/groups.ts b/app/actions/remote/groups.ts index 7976f2a76..27aea731e 100644 --- a/app/actions/remote/groups.ts +++ b/app/actions/remote/groups.ts @@ -11,20 +11,41 @@ import {forceLogoutIfNecessary} from './session'; export const fetchGroupsForAutocomplete = async (serverUrl: string, query: string, fetchOnly = false) => { try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const client: Client = NetworkManager.getClient(serverUrl); const response = await client.getGroups(query); // Save locally if (!fetchOnly) { - return await storeGroups(serverUrl, response); + return storeGroups(serverUrl, response); } - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - throw new Error(`${serverUrl} operator not found`); + return prepareGroups(operator, response); + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const fetchGroupsByNames = async (serverUrl: string, names: string[], fetchOnly = false) => { + try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const client: Client = NetworkManager.getClient(serverUrl); + const promises: Array > = []; + + names.forEach((name) => { + promises.push(client.getGroups(name)); + }); + + const groups = (await Promise.all(promises)).flat(); + + // Save locally + if (!fetchOnly) { + return storeGroups(serverUrl, groups); } - return await prepareGroups(operator, response); + return prepareGroups(operator, groups); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; @@ -33,19 +54,15 @@ export const fetchGroupsForAutocomplete = async (serverUrl: string, query: strin export const fetchGroupsForChannel = async (serverUrl: string, channelId: string, fetchOnly = false) => { try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const client = NetworkManager.getClient(serverUrl); const response = await client.getAllGroupsAssociatedToChannel(channelId); if (!fetchOnly) { - return await storeGroups(serverUrl, response.groups); + return storeGroups(serverUrl, response.groups); } - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - throw new Error(`${serverUrl} operator not found`); - } - - return await prepareGroups(operator, response.groups); + return prepareGroups(operator, response.groups); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; @@ -54,20 +71,15 @@ export const fetchGroupsForChannel = async (serverUrl: string, channelId: string export const fetchGroupsForTeam = async (serverUrl: string, teamId: string, fetchOnly = false) => { try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const client: Client = NetworkManager.getClient(serverUrl); const response = await client.getAllGroupsAssociatedToTeam(teamId); if (!fetchOnly) { - return await storeGroups(serverUrl, response.groups); + return storeGroups(serverUrl, response.groups); } - // return await storeGroups(serverUrl, response.groups, true); - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - throw new Error(`${serverUrl} operator not found`); - } - - return await prepareGroups(operator, response.groups); + return prepareGroups(operator, response.groups); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index c8c554045..c35d7d4c4 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -14,11 +14,13 @@ import DatabaseManager from '@database/manager'; import {debounce} from '@helpers/api/general'; import NetworkManager from '@managers/network_manager'; import {getMembersCountByChannelsId, queryChannelsByTypes} from '@queries/servers/channel'; +import {queryGroupsByNames} from '@queries/servers/group'; import {getCurrentTeamId, getCurrentUserId} from '@queries/servers/system'; -import {getCurrentUser, getUserById, prepareUsers, queryAllUsers, queryUsersById, queryUsersByUsername} from '@queries/servers/user'; +import {getCurrentUser, getUserById, prepareUsers, queryAllUsers, queryUsersById, queryUsersByIdsOrUsernames, queryUsersByUsername} from '@queries/servers/user'; import {logError} from '@utils/log'; import {getUserTimezoneProps, removeUserFromList} from '@utils/user'; +import {fetchGroupsByNames} from './groups'; import {forceLogoutIfNecessary} from './session'; import type {Client} from '@client/rest'; @@ -278,6 +280,53 @@ export const fetchStatusInBatch = (serverUrl: string, id: string) => { return debouncedFetchStatusesByIds.apply(null, [serverUrl]); }; +const mentionNames = new Set(); +export const fetchUserOrGroupsByMentionsInBatch = (serverUrl: string, mentionName: string) => { + mentionNames.add(mentionName); + return debouncedFetchUserOrGroupsByMentionNames.apply(null, [serverUrl]); +}; +const debouncedFetchUserOrGroupsByMentionNames = debounce( + (serverUrl: string) => { + fetchUserOrGroupsByMentionNames(serverUrl, Array.from(mentionNames)); + }, + 200, + false, + () => { + mentionNames.clear(); + }, +); + +const fetchUserOrGroupsByMentionNames = async (serverUrl: string, mentions: string[]) => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + // Get any missing users + const usersInDb = await queryUsersByIdsOrUsernames(database, [], mentions).fetch(); + const usersMap = new Set(usersInDb.map((u) => u.username)); + const usernamesToFetch = mentions.filter((m) => !usersMap.has(m)); + + let fetchedUsers; + if (usernamesToFetch.length) { + const {users} = await fetchUsersByUsernames(serverUrl, usernamesToFetch, false); + fetchedUsers = users; + } + + // Get any missing groups + const fetchedUserMentions = new Set(fetchedUsers?.map((u) => u.username)); + const groupsToCheck = usernamesToFetch.filter((m) => !fetchedUserMentions.has(m)); + const groupsInDb = await queryGroupsByNames(database, groupsToCheck).fetch(); + const groupsMap = new Set(groupsInDb.map((g) => g.name)); + const groupsToFetch = groupsToCheck.filter((g) => !groupsMap.has(g)); + + if (groupsToFetch.length) { + await fetchGroupsByNames(serverUrl, groupsToFetch, false); + } + return {data: true}; + } catch (e) { + return {error: e}; + } +}; + export async function fetchStatusByIds(serverUrl: string, userIds: string[], fetchOnly = false) { let client: Client; try { diff --git a/app/components/markdown/at_mention/at_mention.tsx b/app/components/markdown/at_mention/at_mention.tsx index 5518bcb3d..9479642ec 100644 --- a/app/components/markdown/at_mention/at_mention.tsx +++ b/app/components/markdown/at_mention/at_mention.tsx @@ -4,20 +4,24 @@ import {useManagedConfig} from '@mattermost/react-native-emm'; import {Database} from '@nozbe/watermelondb'; import Clipboard from '@react-native-community/clipboard'; -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {useIntl} from 'react-intl'; import {GestureResponderEvent, Keyboard, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {fetchUserOrGroupsByMentionsInBatch} from '@actions/remote/user'; +import {useServerUrl} from '@app/context/server'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; import {Screens} from '@constants'; import {MM_TABLES} from '@constants/database'; import {useTheme} from '@context/theme'; +import GroupModel from '@database/models/server/group'; import UserModel from '@database/models/server/user'; import {bottomSheet, dismissBottomSheet, openAsBottomSheet} from '@screens/navigation'; import {bottomSheetSnapPoint} from '@utils/helpers'; import {displayUsername, getUsersByUsername} from '@utils/user'; +import type GroupModelType from '@typings/database/models/servers/group'; import type UserModelType from '@typings/database/models/servers/user'; type AtMentionProps = { @@ -34,9 +38,10 @@ type AtMentionProps = { teammateNameDisplay: string; textStyle?: StyleProp; users: UserModelType[]; + groups: GroupModel[]; } -const {SERVER: {USER}} = MM_TABLES; +const {SERVER: {GROUP, USER}} = MM_TABLES; const style = StyleSheet.create({ bottomSheet: {flex: 1}, @@ -56,11 +61,14 @@ const AtMention = ({ teammateNameDisplay, textStyle, users, + groups, }: AtMentionProps) => { const intl = useIntl(); const managedConfig = useManagedConfig(); const theme = useTheme(); const insets = useSafeAreaInsets(); + const serverUrl = useServerUrl(); + const user = useMemo(() => { const usersByUsername = getUsersByUsername(users); let mn = mentionName.toLowerCase(); @@ -93,6 +101,47 @@ const AtMention = ({ return user.mentionKeys; }, [currentUserId, mentionKeys, user]); + // Checks if the mention is a group + const group = useMemo(() => { + if (user?.username) { + return undefined; + } + const getGroupsByName = (gs: GroupModelType[]) => { + const groupsByName: Dictionary = {}; + + for (const g of gs) { + groupsByName[g.name] = g; + } + + return groupsByName; + }; + + const groupsByName = getGroupsByName(groups); + let mn = mentionName.toLowerCase(); + + while (mn.length > 0) { + if (groupsByName[mn]) { + return groupsByName[mn]; + } + + // Repeatedly trim off trailing punctuation in case this is at the end of a sentence + if ((/[._-]$/).test(mn)) { + mn = mn.substring(0, mn.length - 1); + } else { + break; + } + } + + // @ts-expect-error: The model constructor is hidden within WDB type definition + return new GroupModel(database.get(GROUP), {name: ''}); + }, [groups, user, mentionName]); + + // Effects + useEffect(() => { + // Fetches and updates the local db store with the mention + fetchUserOrGroupsByMentionsInBatch(serverUrl, mentionName); + }, []); + const openUserProfile = () => { const screen = Screens.USER_PROFILE; const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}); @@ -172,6 +221,10 @@ const AtMention = ({ mention = displayUsername(user, user.locale, teammateNameDisplay); isMention = true; canPress = true; + } else if (group?.name) { + mention = group.name; + isMention = true; + canPress = false; } else { const pattern = new RegExp(/\b(all|channel|here)(?:\.\B|_\b|\b)/, 'i'); const mentionMatch = pattern.exec(mentionName); diff --git a/app/components/markdown/at_mention/index.ts b/app/components/markdown/at_mention/index.ts index f91eafe4e..7116548ae 100644 --- a/app/components/markdown/at_mention/index.ts +++ b/app/components/markdown/at_mention/index.ts @@ -4,6 +4,7 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; +import {queryGroupsByName} from '@app/queries/servers/group'; import {observeCurrentUserId} from '@queries/servers/system'; import {observeTeammateNameDisplay, queryUsersLike} from '@queries/servers/user'; @@ -24,6 +25,7 @@ const enhance = withObservables(['mentionName'], ({database, mentionName}: {ment currentUserId, teammateNameDisplay, users: queryUsersLike(database, mn).observeWithColumns(['username']), + groups: queryGroupsByName(database, mn).observeWithColumns(['name']), }; }); diff --git a/app/queries/servers/group.ts b/app/queries/servers/group.ts index e414bc114..714ff0a5f 100644 --- a/app/queries/servers/group.ts +++ b/app/queries/servers/group.ts @@ -16,6 +16,12 @@ export const queryGroupsByName = (database: Database, name: string) => { ); }; +export const queryGroupsByNames = (database: Database, names: string[]) => { + return database.collections.get(GROUP).query( + Q.where('name', Q.oneOf(names)), + ); +}; + export const queryGroupsByNameInTeam = (database: Database, name: string, teamId: string) => { return database.collections.get(GROUP).query( Q.on(GROUP_TEAM, 'team_id', teamId), diff --git a/app/utils/markdown/index.ts b/app/utils/markdown/index.ts index 612f4dd2a..9a8e10924 100644 --- a/app/utils/markdown/index.ts +++ b/app/utils/markdown/index.ts @@ -98,7 +98,6 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme: Theme) => { fontFamily: 'OpenSans-Bold', }, mention_highlight: { - backgroundColor: theme.mentionHighlightBg, color: theme.mentionHighlightLink, }, search_highlight: {