From 8a32588507ef54daab2cb8b10fe00a014d49d014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Wed, 2 Aug 2023 11:51:33 +0200 Subject: [PATCH] Fix DMs in sidebar (#7468) * Fix DMs in sidebar * Properly handle view, show and open preferences * Refactor openChannel to keep similar logic together --- app/actions/remote/channel.ts | 29 ++++++- app/actions/remote/preference.ts | 84 +++++++++++++++++-- app/actions/remote/user.ts | 42 ++++++++++ app/actions/websocket/index.ts | 3 + app/actions/websocket/posts.ts | 3 + .../channel_icon/dm_avatar/dm_avatar.tsx | 19 ++++- .../channel_icon/dm_avatar/index.ts | 1 + app/queries/servers/channel.ts | 43 +++------- app/queries/servers/user.ts | 10 +++ .../categories_list/categories/body/index.ts | 9 +- app/utils/categories.ts | 17 ++-- 11 files changed, 204 insertions(+), 56 deletions(-) diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 9d1f429b6..296b9f424 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -33,7 +33,7 @@ import {displayGroupMessageName, displayUsername} from '@utils/user'; import {fetchGroupsForChannelIfConstrained} from './groups'; import {fetchPostsForChannel} from './post'; -import {setDirectChannelVisible} from './preference'; +import {openChannelIfNeeded, savePreference} from './preference'; import {fetchRolesIfNeeded} from './role'; import {forceLogoutIfNecessary} from './session'; import {addCurrentUserToTeam, fetchTeamByName, removeCurrentUserFromTeam} from './team'; @@ -764,6 +764,7 @@ export async function createDirectChannel(serverUrl: string, userId: string, dis const channelName = getDirectChannelName(currentUser.id, userId); const channel = await getChannelByName(database, '', channelName); if (channel) { + openChannelIfNeeded(serverUrl, channel.id); return {data: channel.toApi()}; } @@ -798,6 +799,16 @@ export async function createDirectChannel(serverUrl: string, userId: string, dis }; const models = []; + + const preferences = [ + {user_id: currentUser.id, category: Preferences.CATEGORIES.DIRECT_CHANNEL_SHOW, name: userId, value: 'true'}, + {user_id: currentUser.id, category: Preferences.CATEGORIES.CHANNEL_OPEN_TIME, name: created.id, value: new Date().getTime().toString()}, + ]; + const preferenceModels = await savePreference(serverUrl, preferences, true); + if (preferenceModels.preferences?.length) { + models.push(...preferenceModels.preferences); + } + const channelPromises = await prepareMyChannelsForTeam(operator, '', [created], [member, {...member, user_id: userId}]); if (channelPromises.length) { const channelModels = await Promise.all(channelPromises); @@ -893,14 +904,15 @@ export async function createGroupChannel(serverUrl: string, userIds: string[]) { // Check the channel previous existency: if the channel already have // posts is because it existed before. if (created.total_msg_count > 0) { + openChannelIfNeeded(serverUrl, created.id); EphemeralStore.creatingChannel = false; return {data: created}; } - const preferences = await queryDisplayNamePreferences(database, Preferences.NAME_NAME_FORMAT).fetch(); + const displayNamePreferences = await queryDisplayNamePreferences(database, Preferences.NAME_NAME_FORMAT).fetch(); const license = await getLicense(database); const config = await getConfig(database); - const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license); + const teammateDisplayNameSetting = getTeammateNameDisplaySetting(displayNamePreferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license); const {directChannels, users} = await fetchMissingDirectChannelsInfo(serverUrl, [created], currentUser.locale, teammateDisplayNameSetting, currentUser.id, true); const member = { @@ -931,6 +943,15 @@ export async function createGroupChannel(serverUrl: string, userIds: string[]) { models.push(...categoryModels.models); } + const preferences = [ + {user_id: currentUser.id, category: Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, name: created.id, value: 'true'}, + {user_id: currentUser.id, category: Preferences.CATEGORIES.CHANNEL_OPEN_TIME, name: created.id, value: new Date().getTime().toString()}, + ]; + const preferenceModels = await savePreference(serverUrl, preferences, true); + if (preferenceModels.preferences?.length) { + models.push(...preferenceModels.preferences); + } + models.push(...userModels); operator.batchRecords(models, 'createGroupChannel'); } @@ -1012,7 +1033,7 @@ export async function switchToChannelById(serverUrl: string, channelId: string, fetchPostsForChannel(serverUrl, channelId); await switchToChannel(serverUrl, channelId, teamId, skipLastUnread); - setDirectChannelVisible(serverUrl, channelId); + openChannelIfNeeded(serverUrl, channelId); markChannelAsRead(serverUrl, channelId); fetchChannelStats(serverUrl, channelId); fetchGroupsForChannelIfConstrained(serverUrl, channelId); diff --git a/app/actions/remote/preference.ts b/app/actions/remote/preference.ts index c69207baa..5e390efea 100644 --- a/app/actions/remote/preference.ts +++ b/app/actions/remote/preference.ts @@ -6,18 +6,22 @@ import {DeviceEventEmitter} from 'react-native'; import {handleReconnect} from '@actions/websocket'; import {Events, General, Preferences} from '@constants'; import DatabaseManager from '@database/manager'; +import {getPreferenceAsBool} from '@helpers/api/preference'; import NetworkManager from '@managers/network_manager'; -import {getChannelById} from '@queries/servers/channel'; +import {queryAllUnreadDMsAndGMsIds, getChannelById} from '@queries/servers/channel'; import {truncateCrtRelatedTables} from '@queries/servers/entry'; -import {querySavedPostsPreferences} from '@queries/servers/preference'; +import {queryPreferencesByCategoryAndName, querySavedPostsPreferences} from '@queries/servers/preference'; import {getCurrentUserId} from '@queries/servers/system'; import EphemeralStore from '@store/ephemeral_store'; +import {isDMorGM} from '@utils/channel'; import {getFullErrorMessage} from '@utils/errors'; import {logDebug} from '@utils/log'; import {getUserIdFromChannelName} from '@utils/user'; import {forceLogoutIfNecessary} from './session'; +import type ChannelModel from '@typings/database/models/servers/channel'; + export type MyPreferencesRequest = { preferences?: PreferenceType[]; error?: unknown; @@ -79,19 +83,23 @@ export const savePostPreference = async (serverUrl: string, postId: string) => { } }; -export const savePreference = async (serverUrl: string, preferences: PreferenceType[]) => { +export const savePreference = async (serverUrl: string, preferences: PreferenceType[], prepareRecordsOnly = false) => { try { + if (!preferences.length) { + return {preferences: []}; + } + const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const userId = await getCurrentUserId(database); client.savePreferences(userId, preferences); - await operator.handlePreferences({ + const preferenceModels = await operator.handlePreferences({ preferences, - prepareRecordsOnly: false, + prepareRecordsOnly, }); - return {preferences}; + return {preferences: preferenceModels}; } catch (error) { logDebug('error on savePreference', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); @@ -128,6 +136,70 @@ export const deleteSavedPost = async (serverUrl: string, postId: string) => { } }; +export const openChannelIfNeeded = async (serverUrl: string, channelId: string) => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const channel = await getChannelById(database, channelId); + if (!channel || !isDMorGM(channel)) { + return {}; + } + const res = await openChannels(serverUrl, [channel]); + return res; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +export const openAllUnreadChannels = async (serverUrl: string) => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const channels = await queryAllUnreadDMsAndGMsIds(database).fetch(); + const res = await openChannels(serverUrl, channels); + return res; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +const openChannels = async (serverUrl: string, channels: ChannelModel[]) => { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const userId = await getCurrentUserId(database); + + const {DIRECT_CHANNEL_SHOW, GROUP_CHANNEL_SHOW} = Preferences.CATEGORIES; + const directChannelShowPreferences = await queryPreferencesByCategoryAndName(database, DIRECT_CHANNEL_SHOW).fetch(); + const groupChannelShowPreferences = await queryPreferencesByCategoryAndName(database, GROUP_CHANNEL_SHOW).fetch(); + const showPreferences = directChannelShowPreferences.concat(groupChannelShowPreferences); + + const prefs: PreferenceType[] = []; + for (const channel of channels) { + const category = channel.type === General.DM_CHANNEL ? DIRECT_CHANNEL_SHOW : GROUP_CHANNEL_SHOW; + const name = channel.type === General.DM_CHANNEL ? getUserIdFromChannelName(userId, channel.name) : channel.id; + const visible = getPreferenceAsBool(showPreferences, category, name, false); + if (visible) { + continue; + } + + prefs.push( + { + user_id: userId, + category, + name, + value: 'true', + }, + { + user_id: userId, + category: Preferences.CATEGORIES.CHANNEL_OPEN_TIME, + name: channel.id, + value: Date.now().toString(), + }, + ); + } + + return savePreference(serverUrl, prefs); +}; + export const setDirectChannelVisible = async (serverUrl: string, channelId: string, visible = true) => { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index 184ee6d31..acc5e92a5 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -360,6 +360,48 @@ export async function fetchStatusByIds(serverUrl: string, userIds: string[], fet } } +let usersByIdBatch: { + serverUrl: string; + userIds: Set; + timeout?: NodeJS.Timeout; +} | undefined; +const TIME_TO_BATCH = 500; + +const processBatch = () => { + if (!usersByIdBatch) { + return; + } + + if (usersByIdBatch.timeout) { + clearTimeout(usersByIdBatch.timeout); + } + if (usersByIdBatch.userIds.size) { + fetchUsersByIds(usersByIdBatch.serverUrl, Array.from(usersByIdBatch.userIds)); + } + + usersByIdBatch = undefined; +}; + +export const fetchUserByIdBatched = async (serverUrl: string, userId: string) => { + if (serverUrl !== usersByIdBatch?.serverUrl) { + processBatch(); + } + + if (!usersByIdBatch) { + usersByIdBatch = { + serverUrl, + userIds: new Set(), + }; + } + + if (usersByIdBatch.timeout) { + clearTimeout(usersByIdBatch.timeout); + } + + usersByIdBatch.userIds.add(userId); + usersByIdBatch.timeout = setTimeout(processBatch, TIME_TO_BATCH); +}; + export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetchOnly = false) => { if (!userIds.length) { return {users: [], existingUsers: []}; diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 1760f1201..d05243b9e 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -7,6 +7,7 @@ import {markChannelAsRead} from '@actions/remote/channel'; import {handleEntryAfterLoadNavigation, registerDeviceToken} from '@actions/remote/entry/common'; import {deferredAppEntryActions, entry} from '@actions/remote/entry/gql_common'; import {fetchPostsForChannel, fetchPostThread} from '@actions/remote/post'; +import {openAllUnreadChannels} from '@actions/remote/preference'; import {autoUpdateTimezone} from '@actions/remote/user'; import {loadConfigAndCalls} from '@calls/actions/calls'; import { @@ -152,6 +153,8 @@ async function doReconnect(serverUrl: string) { await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId); + openAllUnreadChannels(serverUrl); + dataRetentionCleanup(serverUrl); AppsManager.refreshAppBindings(serverUrl); diff --git a/app/actions/websocket/posts.ts b/app/actions/websocket/posts.ts index 18b7f98ba..5557e4911 100644 --- a/app/actions/websocket/posts.ts +++ b/app/actions/websocket/posts.ts @@ -8,6 +8,7 @@ import {addPostAcknowledgement, markPostAsDeleted, removePostAcknowledgement} fr import {createThreadFromNewPost, updateThread} from '@actions/local/thread'; import {fetchChannelStats, fetchMyChannel} from '@actions/remote/channel'; import {fetchPostAuthors, fetchPostById} from '@actions/remote/post'; +import {openChannelIfNeeded} from '@actions/remote/preference'; import {fetchThread} from '@actions/remote/thread'; import {fetchMissingProfilesByIds} from '@actions/remote/user'; import {ActionType, Events, Screens} from '@constants'; @@ -157,6 +158,8 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag models.push(unreadAt); } } + + openChannelIfNeeded(serverUrl, post.channel_id); } let actionType: string = ActionType.POSTS.RECEIVED_NEW; diff --git a/app/components/channel_icon/dm_avatar/dm_avatar.tsx b/app/components/channel_icon/dm_avatar/dm_avatar.tsx index 04db4f3c0..bebf323ca 100644 --- a/app/components/channel_icon/dm_avatar/dm_avatar.tsx +++ b/app/components/channel_icon/dm_avatar/dm_avatar.tsx @@ -1,10 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useEffect} from 'react'; +import {fetchUserByIdBatched} from '@actions/remote/user'; import CompassIcon from '@components/compass_icon'; import ProfilePicture from '@components/profile_picture'; +import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -12,6 +14,7 @@ import type UserModel from '@typings/database/models/servers/user'; import type {StyleProp, ViewStyle} from 'react-native'; type Props = { + authorId: string; author?: UserModel; isOnCenterBg?: boolean; style: StyleProp; @@ -35,10 +38,22 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const DmAvatar = ({author, isOnCenterBg, style, size}: Props) => { +const DmAvatar = ({ + authorId, + author, + isOnCenterBg, + style, + size, +}: Props) => { const theme = useTheme(); + const serverUrl = useServerUrl(); const styles = getStyleSheet(theme); + useEffect(() => { + if (authorId && !author) { + fetchUserByIdBatched(serverUrl, authorId); + } + }, []); if (author?.deleteAt) { return ( ); }; +export const queryAllUnreadDMsAndGMsIds = (database: Database) => { + return database.get(CHANNEL).query( + Q.on(MY_CHANNEL, Q.or( + Q.where('mentions_count', Q.gt(0)), + Q.where('message_count', Q.gt(0)), + )), + Q.where('type', Q.oneOf([General.GM_CHANNEL, General.DM_CHANNEL])), + ); +}; + export const getMyChannel = async (database: Database, channelId: string) => { try { const member = await database.get(MY_CHANNEL).find(channelId); @@ -463,38 +472,6 @@ export const queryMyChannelUnreads = (database: Database, currentTeamId: string) ); }; -export const observeArchivedDirectChannels = (database: Database, currentUserId: string) => { - const deactivated = database.get(USER).query( - Q.where('delete_at', Q.gt(0)), - ).observe(); - - return deactivated.pipe( - switchMap((users) => { - const usersMap = new Map(users.map((u) => [u.id, u])); - return database.get(CHANNEL).query( - Q.on( - CHANNEL_MEMBERSHIP, - Q.and( - Q.where('user_id', Q.notEq(currentUserId)), - Q.where('user_id', Q.oneOf(Array.from(usersMap.keys()))), - ), - ), - Q.where('type', 'D'), - ).observe().pipe( - switchMap((channels) => { - // eslint-disable-next-line max-nested-callbacks - return of$(new Map(channels.map((c) => { - const teammateId = getUserIdFromChannelName(currentUserId, c.name); - const user = usersMap.get(teammateId); - - return [c.id, user]; - }))); - }), - ); - }), - ); -}; - export function observeMyChannelMentionCount(database: Database, teamId?: string, columns = ['mentions_count', 'is_unread']): Observable { const conditions: Q.Where[] = [ Q.where('delete_at', Q.eq(0)), diff --git a/app/queries/servers/user.ts b/app/queries/servers/user.ts index 42eab9174..0839e1ee0 100644 --- a/app/queries/servers/user.ts +++ b/app/queries/servers/user.ts @@ -125,3 +125,13 @@ export const observeUserIsChannelAdmin = (database: Database, userId: string, ch distinctUntilChanged(), ); }; + +export const observeDeactivatedUsers = (database: Database) => { + return database.get(USER).query( + Q.where('delete_at', Q.gt(0)), + ).observe().pipe( + switchMap((users) => { + return of$(new Map(users.map((u) => [u.id, u]))); + }), + ); +}; diff --git a/app/screens/home/channel_list/categories_list/categories/body/index.ts b/app/screens/home/channel_list/categories_list/categories/body/index.ts index 6dec3dc92..47d02a396 100644 --- a/app/screens/home/channel_list/categories_list/categories/body/index.ts +++ b/app/screens/home/channel_list/categories_list/categories/body/index.ts @@ -9,9 +9,10 @@ import {switchMap, combineLatestWith, distinctUntilChanged} from 'rxjs/operators import {Preferences} from '@constants'; import {DMS_CATEGORY} from '@constants/categories'; import {getSidebarPreferenceAsBool} from '@helpers/api/preference'; -import {observeArchivedDirectChannels, observeNotifyPropsByChannels} from '@queries/servers/channel'; +import {observeNotifyPropsByChannels} from '@queries/servers/channel'; import {queryPreferencesByCategoryAndName, querySidebarPreferences} from '@queries/servers/preference'; import {observeCurrentChannelId, observeCurrentUserId, observeLastUnreadChannelId} from '@queries/servers/system'; +import {observeDeactivatedUsers} from '@queries/servers/user'; import {type ChannelWithMyChannel, filterArchivedChannels, filterAutoclosedDMs, filterManuallyClosedDms, getUnreadIds, sortChannels} from '@utils/categories'; import CategoryBody from './category_body'; @@ -106,15 +107,15 @@ const enhanced = withObservables([], ({category, currentUserId, database, isTabl distinctUntilChanged(), ); - const deactivated = (category.type === DMS_CATEGORY) ? observeArchivedDirectChannels(database, currentUserId) : of$(undefined); + const deactivated = (category.type === DMS_CATEGORY) ? observeDeactivatedUsers(database) : of$(undefined); const sortedChannels = channelsWithMyChannel.pipe( combineLatestWith(categorySorting, currentChannelId, lastUnreadId, notifyPropsPerChannel, manuallyClosedPrefs, autoclosePrefs, deactivated, limit), - switchMap(([cwms, sorting, channelId, unreadId, notifyProps, manuallyClosedDms, autoclose, deactivatedDMS, maxDms]) => { + switchMap(([cwms, sorting, channelId, unreadId, notifyProps, manuallyClosedDms, autoclose, deactivatedUsers, maxDms]) => { let channelsW = cwms; channelsW = filterArchivedChannels(channelsW, channelId); channelsW = filterManuallyClosedDms(channelsW, notifyProps, manuallyClosedDms, currentUserId, unreadId); - channelsW = filterAutoclosedDMs(category.type, maxDms, channelId, channelsW, autoclose, notifyProps, deactivatedDMS, unreadId); + channelsW = filterAutoclosedDMs(category.type, maxDms, currentUserId, channelId, channelsW, autoclose, notifyProps, deactivatedUsers, unreadId); return of$(sortChannels(sorting, channelsW, notifyProps, locale)); }), diff --git a/app/utils/categories.ts b/app/utils/categories.ts index 569045985..7b2d3e604 100644 --- a/app/utils/categories.ts +++ b/app/utils/categories.ts @@ -32,10 +32,10 @@ export const filterArchivedChannels = (channelsWithMyChannel: ChannelWithMyChann }; export const filterAutoclosedDMs = ( - categoryType: CategoryType, limit: number, currentChannelId: string, + categoryType: CategoryType, limit: number, currentUserId: string, currentChannelId: string, channelsWithMyChannel: ChannelWithMyChannel[], preferences: PreferenceModel[], notifyPropsPerChannel: Record>, - deactivatedDMs?: Map, + deactivatedUsers?: Map, lastUnreadChannelId?: string, ) => { if (categoryType !== DMS_CATEGORY) { @@ -71,16 +71,22 @@ export const filterAutoclosedDMs = ( return true; } + const lastViewedAt = getLastViewedAt(cwm); + // DMs with deactivated users will be visible if you're currently viewing them and they were opened // since the user was deactivated if (channel.type === General.DM_CHANNEL) { - const lastViewedAt = getLastViewedAt(cwm); - const teammate = deactivatedDMs?.get(channel.id); + const teammateId = getUserIdFromChannelName(currentUserId, channel.name); + const teammate = deactivatedUsers?.get(teammateId); if (teammate && teammate.deleteAt > lastViewedAt) { return false; } } + if (isDMorGM(channel) && !lastViewedAt) { + return false; + } + return true; }); @@ -138,9 +144,6 @@ export const filterManuallyClosedDms = ( if (!isDMorGM(channel)) { return true; - } else if (!myChannel.lastPostAt) { - // If the direct channel does not have posts we hide it - return false; } if (isUnreadChannel(myChannel, notifyPropsPerChannel[myChannel.id], lastUnreadChannelId)) {