diff --git a/app/actions/local/group.ts b/app/actions/local/group.ts index fc1e3cfef2..a14c38595e 100644 --- a/app/actions/local/group.ts +++ b/app/actions/local/group.ts @@ -3,7 +3,7 @@ import {fetchFilteredChannelGroups, fetchFilteredTeamGroups, fetchGroupsForAutocomplete} from '@actions/remote/groups'; import DatabaseManager from '@database/manager'; -import {prepareGroups, queryGroupsByName, queryGroupsByNameInChannel, queryGroupsByNameInTeam} from '@queries/servers/group'; +import {queryGroupsByName, queryGroupsByNameInChannel, queryGroupsByNameInTeam} from '@queries/servers/group'; import {logError} from '@utils/log'; import type GroupModel from '@typings/database/models/servers/group'; @@ -61,8 +61,7 @@ export const searchGroupsByNameInChannel = async (serverUrl: string, name: strin try { database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database; } catch (e) { - // eslint-disable-next-line no-console - console.log('searchGroupsByNameInChannel - DB Error', e); + logError('searchGroupsByNameInChannel - DB Error', e); return []; } @@ -79,25 +78,3 @@ export const searchGroupsByNameInChannel = async (serverUrl: string, name: strin } }; -/** - * Store fetched groups locally - * - * @param serverUrl string - The Server URL - * @param groups Group[] - The groups fetched from the API - */ -export const storeGroups = async (serverUrl: string, groups: Group[]) => { - try { - const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); - - const preparedGroups = await prepareGroups(operator, groups); - - if (preparedGroups.length) { - operator.batchRecords(preparedGroups); - } - - return preparedGroups; - } catch (e) { - return {error: e}; - } -}; - diff --git a/app/actions/remote/entry/common.ts b/app/actions/remote/entry/common.ts index 4f1dab6de1..ee73b4c987 100644 --- a/app/actions/remote/entry/common.ts +++ b/app/actions/remote/entry/common.ts @@ -29,6 +29,8 @@ import {deleteMyTeams, getAvailableTeamIds, getNthLastChannelFromTeam, queryMyTe import {isDMorGM} from '@utils/channel'; import {processIsCRTEnabled} from '@utils/thread'; +import {fetchGroupsForMember} from '../groups'; + import type ClientError from '@client/rest/error'; export type AppEntryData = { @@ -305,6 +307,10 @@ export async function deferredAppEntryActions( await fetchAllTeams(serverUrl); await updateAllUsersSince(serverUrl, since); + + // Fetch groups for current user + fetchGroupsForMember(serverUrl, currentUserId); + setTimeout(async () => { if (channelsToFetchProfiles?.size) { const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config, license); diff --git a/app/actions/remote/groups.ts b/app/actions/remote/groups.ts index 27aea731e5..89b4345696 100644 --- a/app/actions/remote/groups.ts +++ b/app/actions/remote/groups.ts @@ -1,11 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {storeGroups} from '@actions/local/group'; import {Client} from '@client/rest'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; -import {prepareGroups} from '@queries/servers/group'; import {forceLogoutIfNecessary} from './session'; @@ -15,12 +13,7 @@ export const fetchGroupsForAutocomplete = async (serverUrl: string, query: strin const client: Client = NetworkManager.getClient(serverUrl); const response = await client.getGroups(query); - // Save locally - if (!fetchOnly) { - return storeGroups(serverUrl, response); - } - - return prepareGroups(operator, response); + return operator.handleGroups({groups: response, prepareRecordsOnly: fetchOnly}); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; @@ -41,11 +34,7 @@ export const fetchGroupsByNames = async (serverUrl: string, names: string[], fet const groups = (await Promise.all(promises)).flat(); // Save locally - if (!fetchOnly) { - return storeGroups(serverUrl, groups); - } - - return prepareGroups(operator, groups); + return operator.handleGroups({groups, prepareRecordsOnly: fetchOnly}); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; @@ -58,11 +47,7 @@ export const fetchGroupsForChannel = async (serverUrl: string, channelId: string const client = NetworkManager.getClient(serverUrl); const response = await client.getAllGroupsAssociatedToChannel(channelId); - if (!fetchOnly) { - return storeGroups(serverUrl, response.groups); - } - - return prepareGroups(operator, response.groups); + return operator.handleGroups({groups: response.groups, prepareRecordsOnly: fetchOnly}); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; @@ -75,17 +60,33 @@ export const fetchGroupsForTeam = async (serverUrl: string, teamId: string, fetc const client: Client = NetworkManager.getClient(serverUrl); const response = await client.getAllGroupsAssociatedToTeam(teamId); - if (!fetchOnly) { - return storeGroups(serverUrl, response.groups); - } - - return prepareGroups(operator, response.groups); + return operator.handleGroups({groups: response.groups, prepareRecordsOnly: fetchOnly}); } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } }; +export const fetchGroupsForMember = async (serverUrl: string, userId: string, fetchOnly = false) => { + try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const client: Client = NetworkManager.getClient(serverUrl); + const response = await client.getAllGroupsAssociatedToMembership(userId); + + const groups = await operator.handleGroups({groups: response, prepareRecordsOnly: true}); + const groupMemberships = await operator.handleGroupMembershipsForMember({groups: response, userId, prepareRecordsOnly: true}); + + if (!fetchOnly) { + await operator.batchRecords([...groups, ...groupMemberships]); + } + + return {groups, groupMemberships}; + } catch (error) { + return {error}; + } +}; + export const fetchFilteredTeamGroups = async (serverUrl: string, searchTerm: string, teamId: string) => { try { const groups = await fetchGroupsForTeam(serverUrl, teamId); diff --git a/app/client/rest/groups.ts b/app/client/rest/groups.ts index f0e6a5740c..ff7aaa775e 100644 --- a/app/client/rest/groups.ts +++ b/app/client/rest/groups.ts @@ -8,10 +8,10 @@ import {PER_PAGE_DEFAULT} from './constants'; export interface ClientGroupsMix { getGroups: (query?: string, filterAllowReference?: boolean, page?: number, perPage?: number, since?: number) => Promise; getAllGroupsAssociatedToChannel: (channelId: string, filterAllowReference?: boolean) => Promise<{groups: Group[]; total_group_count: number}>; - getAllGroupsAssociatedToMembership: (userId: string, filterAllowReference?: boolean) => Promise<{groups: Group[]; total_group_count: number}>; + getAllGroupsAssociatedToMembership: (userId: string, filterAllowReference?: boolean) => Promise; getAllGroupsAssociatedToTeam: (teamId: string, filterAllowReference?: boolean) => Promise<{groups: Group[]; total_group_count: number}>; getAllChannelsAssociatedToGroup: (groupId: string, filterAllowReference?: boolean) => Promise<{groupChannels: GroupChannel[]}>; - getAllMembershipsAssociatedToGroup: (groupId: string, filterAllowReference?: boolean) => Promise<{groupMemberships: GroupMembership; total_member_count: number}>; + getAllMembershipsAssociatedToGroup: (groupId: string, filterAllowReference?: boolean) => Promise<{groupMemberships: UserProfile[]; total_member_count: number}>; getAllTeamsAssociatedToGroup: (groupId: string, filterAllowReference?: boolean) => Promise<{groupTeams: GroupTeam[]}>; } diff --git a/app/components/markdown/at_mention/at_mention.tsx b/app/components/markdown/at_mention/at_mention.tsx index 9479642ec8..c6aa97b8bd 100644 --- a/app/components/markdown/at_mention/at_mention.tsx +++ b/app/components/markdown/at_mention/at_mention.tsx @@ -22,6 +22,7 @@ import {bottomSheetSnapPoint} from '@utils/helpers'; import {displayUsername, getUsersByUsername} from '@utils/user'; import type GroupModelType from '@typings/database/models/servers/group'; +import type GroupMembershipModel from '@typings/database/models/servers/group_membership'; import type UserModelType from '@typings/database/models/servers/user'; type AtMentionProps = { @@ -39,6 +40,7 @@ type AtMentionProps = { textStyle?: StyleProp; users: UserModelType[]; groups: GroupModel[]; + groupMemberships: GroupMembershipModel[]; } const {SERVER: {GROUP, USER}} = MM_TABLES; @@ -62,6 +64,7 @@ const AtMention = ({ textStyle, users, groups, + groupMemberships, }: AtMentionProps) => { const intl = useIntl(); const managedConfig = useManagedConfig(); @@ -223,6 +226,7 @@ const AtMention = ({ canPress = true; } else if (group?.name) { mention = group.name; + highlighted = groupMemberships.some((gm) => gm.groupId === group.id); isMention = true; canPress = false; } else { diff --git a/app/components/markdown/at_mention/index.ts b/app/components/markdown/at_mention/index.ts index 7116548aef..7713b1d865 100644 --- a/app/components/markdown/at_mention/index.ts +++ b/app/components/markdown/at_mention/index.ts @@ -3,8 +3,9 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; +import {switchMap} from 'rxjs/operators'; -import {queryGroupsByName} from '@app/queries/servers/group'; +import {queryGroupsByName, queryGroupMembershipForMember} from '@queries/servers/group'; import {observeCurrentUserId} from '@queries/servers/system'; import {observeTeammateNameDisplay, queryUsersLike} from '@queries/servers/user'; @@ -26,6 +27,7 @@ const enhance = withObservables(['mentionName'], ({database, mentionName}: {ment teammateNameDisplay, users: queryUsersLike(database, mn).observeWithColumns(['username']), groups: queryGroupsByName(database, mn).observeWithColumns(['name']), + groupMemberships: currentUserId.pipe(switchMap((userId) => queryGroupMembershipForMember(database, userId).observe())), }; }); diff --git a/app/database/operator/server_data_operator/handlers/group.ts b/app/database/operator/server_data_operator/handlers/group.ts index 054c48f3b6..1b767eff49 100644 --- a/app/database/operator/server_data_operator/handlers/group.ts +++ b/app/database/operator/server_data_operator/handlers/group.ts @@ -2,16 +2,21 @@ // See LICENSE.txt for license information. import {MM_TABLES} from '@constants/database'; -import {transformGroupRecord} from '@database/operator/server_data_operator/transformers/group'; +import {transformGroupMembershipRecord, transformGroupRecord} from '@database/operator/server_data_operator/transformers/group'; import {getUniqueRawsBy} from '@database/operator/utils/general'; +import {queryGroupMembershipForMember} from '@queries/servers/group'; +import {generateGroupAssociationId} from '@utils/groups'; import {logWarning} from '@utils/log'; -import type {HandleGroupArgs} from '@typings/database/database'; +import type {HandleGroupArgs, HandleGroupMembershipForMemberArgs} from '@typings/database/database'; import type GroupModel from '@typings/database/models/servers/group'; +import type GroupMembershipModel from '@typings/database/models/servers/group_membership'; + +const {GROUP, GROUP_MEMBERSHIP} = MM_TABLES.SERVER; -const {GROUP} = MM_TABLES.SERVER; export interface GroupHandlerMix { handleGroups: ({groups, prepareRecordsOnly}: HandleGroupArgs) => Promise; + handleGroupMembershipsForMember: ({userId, groups, prepareRecordsOnly}: HandleGroupMembershipForMemberArgs) => Promise; } const GroupHandler = (superclass: any) => class extends superclass implements GroupHandlerMix { @@ -41,6 +46,66 @@ const GroupHandler = (superclass: any) => class extends superclass implements Gr prepareRecordsOnly, }); }; + + /** + * handleGroupMembershipsForMember: Handler responsible for the Create/Update operations occurring on the GroupMembership table from the 'Server' schema + * @param {string} userId + * @param {HandleGroupMembershipForMemberArgs} groupMembershipsArgs + * @param {GroupMembership[]} groupMembershipsArgs.groupMemberships + * @param {boolean} groupMembershipsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {Promise} + */ + handleGroupMembershipsForMember = async ({userId, groups, prepareRecordsOnly = true}: HandleGroupMembershipForMemberArgs): Promise => { + // Get existing group memberships + const existingGroupMemberships = await queryGroupMembershipForMember(this.database, userId).fetch(); + + let records: GroupMembershipModel[] = []; + let rawValues: GroupMembership[] = []; + + // Nothing to add or remove + if (!groups?.length && !existingGroupMemberships.length) { + return records; + } else if (!groups?.length && existingGroupMemberships.length) { // No groups - remove all existing ones + records = existingGroupMemberships.map((gm) => gm.prepareDestroyPermanently()); + } else if (groups?.length && !existingGroupMemberships.length) { // No existing groups - add all new ones + rawValues = groups.map((g) => ({id: generateGroupAssociationId(g.id, userId), user_id: userId, group_id: g.id})); + } else if (groups?.length && existingGroupMemberships.length) { // If both, we only want to save new ones and delete one's no longer in groups + const groupsSet: {[key: string]: GroupMembership} = {}; + + for (const g of groups) { + groupsSet[`${g.id}`] = {id: generateGroupAssociationId(g.id, userId), user_id: userId, group_id: g.id}; + } + + for (const gm of existingGroupMemberships) { + // Check if existingGroups overlaps with groups + if (groupsSet[gm.groupId]) { + // If there is an existing group already, we don't need to add it + delete groupsSet[gm.groupId]; + } else { + // No group? Remove existing one + records.push(gm.prepareDestroyPermanently()); + } + } + + rawValues.push(...Object.values(groupsSet)); + } + + records.push(...(await this.handleRecords({ + fieldName: 'id', + transformer: transformGroupMembershipRecord, + rawValues, + tableName: GROUP_MEMBERSHIP, + prepareRecordsOnly: true, + }))); + + // Batch update if there are records + if (records.length && !prepareRecordsOnly) { + await this.batchRecords(records); + } + + return records; + }; }; export default GroupHandler; diff --git a/app/database/operator/server_data_operator/transformers/group.ts b/app/database/operator/server_data_operator/transformers/group.ts index 69e7755b01..5d796c9248 100644 --- a/app/database/operator/server_data_operator/transformers/group.ts +++ b/app/database/operator/server_data_operator/transformers/group.ts @@ -5,12 +5,15 @@ import {MM_TABLES} from '@constants/database'; import {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index'; import {OperationType} from '@typings/database/enums'; +import {generateGroupAssociationId} from '@utils/groups'; import type {TransformerArgs} from '@typings/database/database'; import type GroupModel from '@typings/database/models/servers/group'; +import type GroupMembershipModel from '@typings/database/models/servers/group_membership'; const { GROUP, + GROUP_MEMBERSHIP, } = MM_TABLES.SERVER; /** @@ -42,3 +45,29 @@ export const transformGroupRecord = ({action, database, value}: TransformerArgs) fieldsMapper, }) as Promise; }; + +/** + * transformGroupMembershipRecord: Prepares a record of the SERVER database 'GroupMembership' table for update or create actions. + * @param {TransformerArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const transformGroupMembershipRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw as GroupMembership; + + // id of group comes from server response + const fieldsMapper = (model: GroupMembershipModel) => { + model._raw.id = raw.id || generateGroupAssociationId(raw.group_id, raw.user_id); + model.groupId = raw.group_id; + model.userId = raw.user_id; + }; + + return prepareBaseRecord({ + action, + database, + tableName: GROUP_MEMBERSHIP, + value, + fieldsMapper, + }) as Promise; +}; diff --git a/app/queries/servers/group.ts b/app/queries/servers/group.ts index 714ff0a5f1..82be34cc0e 100644 --- a/app/queries/servers/group.ts +++ b/app/queries/servers/group.ts @@ -5,10 +5,10 @@ import {Database, Q} from '@nozbe/watermelondb'; import {MM_TABLES} from '@constants/database'; -import type ServerDataOperator from '@database/operator/server_data_operator'; import type GroupModel from '@typings/database/models/servers/group'; +import type GroupMembershipModel from '@typings/database/models/servers/group_membership'; -const {SERVER: {GROUP, GROUP_CHANNEL, GROUP_TEAM}} = MM_TABLES; +const {SERVER: {GROUP, GROUP_CHANNEL, GROUP_MEMBERSHIP, GROUP_TEAM}} = MM_TABLES; export const queryGroupsByName = (database: Database, name: string) => { return database.collections.get(GROUP).query( @@ -36,6 +36,8 @@ export const queryGroupsByNameInChannel = (database: Database, name: string, cha ); }; -export const prepareGroups = (operator: ServerDataOperator, groups: Group[]) => { - return operator.handleGroups({groups, prepareRecordsOnly: true}); +export const queryGroupMembershipForMember = (database: Database, userId: string) => { + return database.collections.get(GROUP_MEMBERSHIP).query( + Q.where('user_id', userId), + ); }; diff --git a/app/utils/groups.ts b/app/utils/groups.ts new file mode 100644 index 0000000000..9d9ab2f9d9 --- /dev/null +++ b/app/utils/groups.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const generateGroupAssociationId = (groupId: string, otherId: string) => `${groupId}-${otherId}`; diff --git a/types/api/groups.d.ts b/types/api/groups.d.ts index 70da4d5b14..2fd4c2e7d3 100644 --- a/types/api/groups.d.ts +++ b/types/api/groups.d.ts @@ -42,4 +42,8 @@ type GroupChannel = { update_at: number; } -type GroupMembership = UserProfile[] +type GroupMembership = { + id?: string; + group_id: string; + user_id: string; +} diff --git a/types/database/database.d.ts b/types/database/database.d.ts index 10bef3780a..bdfbaecac8 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -222,6 +222,11 @@ export type HandleGroupArgs = PrepareOnly & { groups?: Group[]; }; +export type HandleGroupMembershipForMemberArgs = PrepareOnly & { + userId: string; + groups?: Group[]; +} + export type HandleCategoryChannelArgs = PrepareOnly & { categoryChannels?: CategoryChannel[]; };