diff --git a/android/app/src/main/java/com/mattermost/helpers/push_notification/Channel.kt b/android/app/src/main/java/com/mattermost/helpers/push_notification/Channel.kt index 7cf96a6c60..004a3801b6 100644 --- a/android/app/src/main/java/com/mattermost/helpers/push_notification/Channel.kt +++ b/android/app/src/main/java/com/mattermost/helpers/push_notification/Channel.kt @@ -11,6 +11,7 @@ import com.mattermost.helpers.database_extension.queryCurrentUserId import com.nozbe.watermelondb.Database import java.text.Collator import java.util.Locale +import kotlin.math.max suspend fun PushNotificationDataRunnable.Companion.fetchMyChannel(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean): Triple { val channel = fetch(serverUrl, "/api/v4/channels/$channelId") diff --git a/app/constants/preferences.ts b/app/constants/preferences.ts index efc154e0e4..e9fc7a231b 100644 --- a/app/constants/preferences.ts +++ b/app/constants/preferences.ts @@ -3,6 +3,8 @@ export const CATEGORIES_TO_KEEP: Record = { ADVANCED_SETTINGS: 'advanced_settings', + CHANNEL_APPROXIMATE_VIEW_TIME: 'channel_approximate_view_time', + CHANNEL_OPEN_TIME: 'channel_open_time', DIRECT_CHANNEL_SHOW: 'direct_channel_show', GROUP_CHANNEL_SHOW: 'group_channel_show', DISPLAY_SETTINGS: 'display_settings', diff --git a/app/database/operator/server_data_operator/handlers/channel.ts b/app/database/operator/server_data_operator/handlers/channel.ts index aad93db128..807a35e96a 100644 --- a/app/database/operator/server_data_operator/handlers/channel.ts +++ b/app/database/operator/server_data_operator/handlers/channel.ts @@ -246,10 +246,11 @@ const ChannelHandler = >(super const totalMsg = isCRT ? channel.total_msg_count_root! : channel.total_msg_count; const myMsgCount = isCRT ? my.msg_count_root! : my.msg_count; const msgCount = Math.max(0, totalMsg - myMsgCount); + const lastPostAt = isCRT ? (channel.last_root_post_at || channel.last_post_at) : channel.last_post_at; my.msg_count = msgCount; my.mention_count = isCRT ? my.mention_count_root! : my.mention_count; my.is_unread = msgCount > 0; - my.last_post_at = (isCRT ? (channel.last_root_post_at || channel.last_post_at) : channel.last_post_at) || 0; + my.last_post_at = lastPostAt; } } @@ -271,7 +272,7 @@ const ChannelHandler = >(super } const chan = channelMap[my.channel_id]; - const lastPostAt = (isCRT ? chan.last_root_post_at : chan.last_post_at) || 0; + const lastPostAt = isCRT ? (chan.last_root_post_at || chan.last_post_at) : chan.last_post_at; if ((chan && e.lastPostAt < lastPostAt) || e.isUnread !== my.is_unread || e.lastViewedAt < my.last_viewed_at || e.roles !== my.roles diff --git a/app/queries/servers/categories.ts b/app/queries/servers/categories.ts index 7d80a86117..4f2ffdf663 100644 --- a/app/queries/servers/categories.ts +++ b/app/queries/servers/categories.ts @@ -11,14 +11,11 @@ import {makeCategoryChannelId} from '@utils/categories'; import {pluckUnique} from '@utils/helpers'; import {logDebug} from '@utils/log'; -import {observeChannelsByLastPostAt} from './channel'; - import type ServerDataOperator from '@database/operator/server_data_operator'; import type CategoryModel from '@typings/database/models/servers/category'; import type CategoryChannelModel from '@typings/database/models/servers/category_channel'; -import type ChannelModel from '@typings/database/models/servers/channel'; -const {SERVER: {CATEGORY, CATEGORY_CHANNEL, CHANNEL}} = MM_TABLES; +const {SERVER: {CATEGORY, CATEGORY_CHANNEL}} = MM_TABLES; export const getCategoryById = async (database: Database, categoryId: string) => { try { @@ -144,24 +141,3 @@ export const observeIsChannelFavorited = (database: Database, teamId: string, ch distinctUntilChanged(), ); }; - -export const observeChannelsByCategoryChannelSortOrder = (database: Database, category: CategoryModel, excludeIds?: string[]) => { - return category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']).pipe( - switchMap((categoryChannels) => { - const ids = categoryChannels.map((cc) => cc.channelId); - const idsStr = `'${ids.join("','")}'`; - const exclude = excludeIds?.length ? `AND c.id NOT IN ('${excludeIds.join("','")}')` : ''; - return database.get(CHANNEL).query( - Q.unsafeSqlQuery(`SELECT DISTINCT c.* FROM ${CHANNEL} c INNER JOIN - ${CATEGORY_CHANNEL} cc ON cc.channel_id=c.id AND c.id IN (${idsStr}) ${exclude} - ORDER BY cc.sort_order`), - ).observe(); - }), - ); -}; - -export const observeChannelsByLastPostAtInCategory = (database: Database, category: CategoryModel, excludeIds?: string[]) => { - return category.myChannels.observeWithColumns(['last_post_at']).pipe( - switchMap((myChannels) => observeChannelsByLastPostAt(database, myChannels, excludeIds)), - ); -}; diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index ba9b2a8270..3fec061233 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -11,6 +11,7 @@ import {General, Permissions} from '@constants'; import {MM_TABLES} from '@constants/database'; import {sanitizeLikeString} from '@helpers/database'; import {hasPermission} from '@utils/role'; +import {getUserIdFromChannelName} from '@utils/user'; import {prepareDeletePost} from './post'; import {queryRoles} from './role'; @@ -437,10 +438,6 @@ export const observeNotifyPropsByChannels = (database: Database, channels: Chann ); }; -export const queryChannelsByNames = (database: Database, names: string[]) => { - return database.get(CHANNEL).query(Q.where('name', Q.oneOf(names))); -}; - export const queryMyChannelUnreads = (database: Database, currentTeamId: string) => { return database.get(MY_CHANNEL).query( Q.on( @@ -453,40 +450,42 @@ export const queryMyChannelUnreads = (database: Database, currentTeamId: string) Q.where('delete_at', Q.eq(0)), ), ), - Q.where('is_unread', Q.eq(true)), + Q.or( + Q.where('is_unread', Q.eq(true)), + Q.where('mentions_count', Q.gte(0)), + ), Q.sortBy('last_post_at', Q.desc), ); }; -export const queryEmptyDirectAndGroupChannels = (database: Database) => { - return database.get(MY_CHANNEL).query( - Q.on( - CHANNEL, - Q.where('team_id', Q.eq('')), - ), - Q.where('last_post_at', Q.eq(0)), - ); -}; - export const observeArchivedDirectChannels = (database: Database, currentUserId: string) => { - const deactivatedIds = database.get(USER).query( + const deactivated = database.get(USER).query( Q.where('delete_at', Q.gt(0)), - ).observe().pipe( - switchMap((users) => of$(users.map((u) => u.id))), - ); + ).observe(); - return deactivatedIds.pipe( - switchMap((dIds) => { + 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(dIds)), + Q.where('user_id', Q.oneOf(Array.from(usersMap.keys()))), ), ), Q.where('type', 'D'), - ).observe(); + ).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]; + }))); + }), + ); }), ); }; @@ -639,13 +638,13 @@ export const observeIsMutedSetting = (database: Database, channelId: string) => return observeChannelSettings(database, channelId).pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === General.MENTION))); }; -export const observeChannelsByLastPostAt = (database: Database, myChannels: MyChannelModel[], excludeIds?: string[]) => { +export const observeChannelsByLastPostAt = (database: Database, myChannels: MyChannelModel[]) => { const ids = myChannels.map((c) => c.id); const idsStr = `'${ids.join("','")}'`; - const exclude = excludeIds?.length ? `AND c.id NOT IN ('${excludeIds.join("','")}')` : ''; + return database.get(CHANNEL).query( Q.unsafeSqlQuery(`SELECT DISTINCT c.* FROM ${CHANNEL} c INNER JOIN - ${MY_CHANNEL} mc ON mc.id=c.id AND c.id IN (${idsStr}) ${exclude} + ${MY_CHANNEL} mc ON mc.id=c.id AND c.id IN (${idsStr}) ORDER BY CASE mc.last_post_at WHEN 0 THEN c.create_at ELSE mc.last_post_at END DESC`), ).observe(); }; diff --git a/app/screens/home/channel_list/categories_list/categories/body/category_body.test.tsx b/app/screens/home/channel_list/categories_list/categories/body/category_body.test.tsx index 5aec2a54ab..6030c78aad 100644 --- a/app/screens/home/channel_list/categories_list/categories/body/category_body.test.tsx +++ b/app/screens/home/channel_list/categories_list/categories/body/category_body.test.tsx @@ -12,29 +12,9 @@ import TestHelper from '@test/test_helper'; import CategoryBody from '.'; import type CategoryModel from '@typings/database/models/servers/category'; -import type CategoryChannelModel from '@typings/database/models/servers/category_channel'; -import type ChannelModel from '@typings/database/models/servers/channel'; const {SERVER: {CATEGORY}} = MM_TABLES; -jest.mock('@queries/servers/categories', () => { - const Queries = jest.requireActual('@queries/servers/categories'); - const switchMap = jest.requireActual('rxjs/operators').switchMap; - const mQ = jest.requireActual('@nozbe/watermelondb').Q; - - return { - ...Queries, - observeChannelsByCategoryChannelSortOrder: (database: Database, category: CategoryModel, excludeIds?: string[]) => { - return category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']).pipe( - switchMap((categoryChannels: CategoryChannelModel[]) => { - const ids = categoryChannels.filter((cc) => excludeIds?.includes(cc.channelId)).map((cc) => cc.channelId); - return database.get('Channel').query(mQ.where('id', mQ.oneOf(ids))).observe(); - }), - ); - }, - }; -}); - describe('components/channel_list/categories/body', () => { let database: Database; let category: CategoryModel; diff --git a/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx b/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx index d06140dd2f..89d11cdc8e 100644 --- a/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx +++ b/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx @@ -7,7 +7,6 @@ import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 're import {fetchDirectChannelsInfo} from '@actions/remote/channel'; import ChannelItem from '@components/channel_item'; -import {DMS_CATEGORY} from '@constants/categories'; import {useServerUrl} from '@context/server'; import {isDMorGM} from '@utils/channel'; @@ -17,7 +16,6 @@ import type ChannelModel from '@typings/database/models/servers/channel'; type Props = { sortedChannels: ChannelModel[]; category: CategoryModel; - limit: number; onChannelSwitch: (channelId: string) => void; unreadIds: Set; unreadsOnTop: boolean; @@ -25,16 +23,13 @@ type Props = { const extractKey = (item: ChannelModel) => item.id; -const CategoryBody = ({sortedChannels, unreadIds, unreadsOnTop, category, limit, onChannelSwitch}: Props) => { +const CategoryBody = ({sortedChannels, unreadIds, unreadsOnTop, category, onChannelSwitch}: Props) => { const serverUrl = useServerUrl(); const ids = useMemo(() => { const filteredChannels = unreadsOnTop ? sortedChannels.filter((c) => !unreadIds.has(c.id)) : sortedChannels; - if (category.type === DMS_CATEGORY && limit > 0) { - return filteredChannels.slice(0, limit); - } return filteredChannels; - }, [category.type, limit, sortedChannels, unreadIds, unreadsOnTop]); + }, [category.type, sortedChannels, unreadIds, unreadsOnTop]); const unreadChannels = useMemo(() => { return unreadsOnTop ? [] : ids.filter((c) => unreadIds.has(c.id)); 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 ac1949d849..f39675d212 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 @@ -1,33 +1,26 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database, Q} from '@nozbe/watermelondb'; import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import {combineLatest, of as of$} from 'rxjs'; -import {map, switchMap, combineLatestWith} from 'rxjs/operators'; +import {of as of$} from 'rxjs'; +import {switchMap, combineLatestWith, distinctUntilChanged} from 'rxjs/operators'; -import {General, Preferences} from '@constants'; +import {Preferences} from '@constants'; import {DMS_CATEGORY} from '@constants/categories'; import {getSidebarPreferenceAsBool} from '@helpers/api/preference'; -import {observeChannelsByCategoryChannelSortOrder, observeChannelsByLastPostAtInCategory} from '@queries/servers/categories'; -import {observeArchivedDirectChannels, observeNotifyPropsByChannels, queryChannelsByNames, queryEmptyDirectAndGroupChannels} from '@queries/servers/channel'; +import {observeArchivedDirectChannels, observeNotifyPropsByChannels} from '@queries/servers/channel'; import {queryPreferencesByCategoryAndName, querySidebarPreferences} from '@queries/servers/preference'; import {observeCurrentChannelId, observeCurrentUserId, observeLastUnreadChannelId} from '@queries/servers/system'; -import {getDirectChannelName} from '@utils/channel'; +import {ChannelWithMyChannel, filterArchivedChannels, filterAutoclosedDMs, filterManuallyClosedDms, getUnreadIds, sortChannels} from '@utils/categories'; import CategoryBody from './category_body'; import type {WithDatabaseArgs} from '@typings/database/database'; import type CategoryModel from '@typings/database/models/servers/category'; import type ChannelModel from '@typings/database/models/servers/channel'; -import type MyChannelModel from '@typings/database/models/servers/my_channel'; import type PreferenceModel from '@typings/database/models/servers/preference'; -type ChannelData = Pick & { - isMuted: boolean; -}; - type EnhanceProps = { category: CategoryModel; locale: string; @@ -35,87 +28,45 @@ type EnhanceProps = { isTablet: boolean; } & WithDatabaseArgs -const sortAlpha = (locale: string, a: ChannelData, b: ChannelData) => { - if (a.isMuted && !b.isMuted) { - return 1; - } else if (!a.isMuted && b.isMuted) { - return -1; - } - - return a.displayName.localeCompare(b.displayName, locale, {numeric: true}); -}; - -const filterArchived = (channels: Array, currentChannelId: string) => { - return channels.filter((c): c is ChannelModel => c != null && ((c.deleteAt > 0 && c.id === currentChannelId) || !c.deleteAt)); -}; - -const buildAlphaData = (channels: ChannelModel[], notifyProps: Record>, locale: string) => { - const chanelsById = channels.reduce((result: Record, c) => { - result[c.id] = c; - return result; - }, {}); - - const combined = channels.map((c) => { - const s = notifyProps[c.id]; - return { - id: c.id, - displayName: c.displayName, - isMuted: s?.mark_unread === General.MENTION, - }; - }); - - combined.sort(sortAlpha.bind(null, locale)); - return of$(combined.map((cdata) => chanelsById[cdata.id])); -}; - -const observeSortedChannels = (database: Database, category: CategoryModel, excludeIds: string[], locale: string) => { - switch (category.sorting) { - case 'alpha': { - const channels = category.channels.extend(Q.where('id', Q.notIn(excludeIds))).observeWithColumns(['display_name']); - const notifyProps = channels.pipe(switchMap((cs) => observeNotifyPropsByChannels(database, cs))); - return combineLatest([channels, notifyProps]).pipe( - switchMap(([cs, np]) => buildAlphaData(cs, np, locale)), - ); - } - case 'manual': { - return observeChannelsByCategoryChannelSortOrder(database, category, excludeIds); - } - default: - return observeChannelsByLastPostAtInCategory(database, category, excludeIds); - } -}; - -const mapPrefName = (prefs: PreferenceModel[]) => of$(prefs.map((p) => p.name)); - -const mapChannelIds = (channels: ChannelModel[] | MyChannelModel[]) => of$(channels.map((c) => c.id)); - const withUserId = withObservables([], ({database}: WithDatabaseArgs) => ({currentUserId: observeCurrentUserId(database)})); -const enhance = withObservables(['category', 'isTablet', 'locale'], ({category, locale, isTablet, database, currentUserId}: EnhanceProps) => { - const dmMap = (p: PreferenceModel) => getDirectChannelName(p.name, currentUserId); +const observeCategoryChannels = (category: CategoryModel) => { + const myChannels = category.myChannels.observeWithColumns(['last_post_at', 'is_unread']); + const channels = category.channels.observeWithColumns(['create_at', 'display_name']); + const manualSort = category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']); + return myChannels.pipe( + combineLatestWith(channels, manualSort), + switchMap(([my, cs, sorted]) => { + const channelMap = new Map(cs.map((c) => [c.id, c])); + const categoryChannelMap = new Map(sorted.map((s) => [s.channelId, s.sortOrder])); + return of$(my.reduce((result, myChannel) => { + const channel = channelMap.get(myChannel.id); + if (channel) { + const channelWithMyChannel: ChannelWithMyChannel = { + channel, + myChannel, + sortOrder: categoryChannelMap.get(myChannel.id) || 0, + }; + result.push(channelWithMyChannel); + } - const currentChannelId = observeCurrentChannelId(database); + return result; + }, [])); + }), + ); +}; - const hiddenDmIds = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.DIRECT_CHANNEL_SHOW, undefined, 'false'). - observeWithColumns(['value']).pipe( - switchMap((prefs: PreferenceModel[]) => { - const names = prefs.map(dmMap); - const channels = queryChannelsByNames(database, names).observe(); +const enhanced = withObservables([], ({category, currentUserId, database, isTablet, locale}: EnhanceProps) => { + const channelsWithMyChannel = observeCategoryChannels(category); + const currentChannelId = isTablet ? observeCurrentChannelId(database) : of$(''); + const lastUnreadId = isTablet ? observeLastUnreadChannelId(database) : of$(undefined); - return channels.pipe( - switchMap(mapChannelIds), - ); - }), + const unreadsOnTop = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS). + observeWithColumns(['value']). + pipe( + switchMap((prefs: PreferenceModel[]) => of$(getSidebarPreferenceAsBool(prefs, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS))), ); - const emptyDmIds = queryEmptyDirectAndGroupChannels(database).observeWithColumns(['last_post_at']).pipe( - switchMap(mapChannelIds), - ); - - const archivedDmIds = observeArchivedDirectChannels(database, currentUserId).pipe( - switchMap(mapChannelIds), - ); - let limit = of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT); if (category.type === DMS_CATEGORY) { limit = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_LIMIT_DMS). @@ -126,54 +77,61 @@ const enhance = withObservables(['category', 'isTablet', 'locale'], ({category, ); } - const unreadsOnTop = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS). - observeWithColumns(['value']). - pipe( - switchMap((prefs: PreferenceModel[]) => of$(getSidebarPreferenceAsBool(prefs, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS))), - ); - - const lastUnreadId = isTablet ? observeLastUnreadChannelId(database) : of$(undefined); - - const hiddenChannelIds = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, undefined, 'false'). - observeWithColumns(['value']).pipe( - switchMap(mapPrefName), - combineLatestWith(hiddenDmIds, emptyDmIds, archivedDmIds, lastUnreadId), - switchMap(([hIds, hDmIds, eDmIds, aDmIds, excludeId]) => { - const hidden = new Set(hIds.concat(hDmIds, eDmIds, aDmIds)); - if (excludeId) { - hidden.delete(excludeId); - } - return of$(hidden); - }), - ); - const sortedChannels = hiddenChannelIds.pipe( - switchMap((excludeIds) => observeSortedChannels(database, category, Array.from(excludeIds), locale)), - combineLatestWith(currentChannelId), - map(([channels, ccId]) => filterArchived(channels, ccId)), + const notifyPropsPerChannel = channelsWithMyChannel.pipe( + // eslint-disable-next-line max-nested-callbacks + switchMap((cwms) => observeNotifyPropsByChannels(database, cwms.map((c) => c.myChannel))), ); - const unreadChannels = category.myChannels.observeWithColumns(['mentions_count', 'is_unread']); - const notifyProps = unreadChannels.pipe(switchMap((myChannels) => observeNotifyPropsByChannels(database, myChannels))); - const unreadIds = unreadChannels.pipe( - combineLatestWith(notifyProps, lastUnreadId), - map(([my, settings, lastUnread]) => { - return my.reduce>((set, m) => { - const isMuted = settings[m.id]?.mark_unread === 'mention'; - if ((isMuted && m.mentionsCount) || (!isMuted && m.isUnread) || m.id === lastUnread) { - set.add(m.id); - } - return set; - }, new Set()); + const hiddenDmPrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.DIRECT_CHANNEL_SHOW, undefined, 'false'). + observeWithColumns(['value']); + const hiddenGmPrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, undefined, 'false'). + observeWithColumns(['value']); + const manuallyClosedPrefs = hiddenDmPrefs.pipe( + combineLatestWith(hiddenGmPrefs), + switchMap(([dms, gms]) => of$(dms.concat(gms))), + ); + + const approxViewTimePrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.CHANNEL_APPROXIMATE_VIEW_TIME, undefined). + observeWithColumns(['value']); + const openTimePrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.CHANNEL_OPEN_TIME, undefined). + observeWithColumns(['value']); + const autoclosePrefs = approxViewTimePrefs.pipe( + combineLatestWith(openTimePrefs), + switchMap(([viewTimes, openTimes]) => of$(viewTimes.concat(openTimes))), + ); + + const categorySorting = category.observe().pipe( + switchMap((c) => of$(c.sorting)), + distinctUntilChanged(), + ); + + const deactivated = (category.type === DMS_CATEGORY) ? observeArchivedDirectChannels(database, currentUserId) : 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]) => { + 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); + + return of$(sortChannels(sorting, channelsW, notifyProps, locale)); + }), + ); + + const unreadIds = channelsWithMyChannel.pipe( + combineLatestWith(notifyPropsPerChannel, lastUnreadId), + switchMap(([cwms, notifyProps, unreadId]) => { + return of$(getUnreadIds(cwms, notifyProps, unreadId)); }), ); return { - limit, - sortedChannels, - unreadsOnTop, - unreadIds, category, + sortedChannels, + unreadIds, + unreadsOnTop, }; }); -export default withDatabase(withUserId(enhance(CategoryBody))); +export default withDatabase(withUserId(enhanced(CategoryBody))); diff --git a/app/screens/home/channel_list/categories_list/categories/unreads/index.ts b/app/screens/home/channel_list/categories_list/categories/unreads/index.ts index dee116dd6b..8c3a5c1642 100644 --- a/app/screens/home/channel_list/categories_list/categories/unreads/index.ts +++ b/app/screens/home/channel_list/categories_list/categories/unreads/index.ts @@ -54,7 +54,7 @@ const enhanced = withObservables(['currentTeamId', 'isTablet', 'onlyUnreads'], ( const channels = myUnreadChannels.pipe(switchMap((myChannels) => observeChannelsByLastPostAt(database, myChannels))); const channelsMap = channels.pipe(switchMap((cs) => of$(makeChannelsMap(cs)))); - return queryMyChannelUnreads(database, currentTeamId).observeWithColumns(['last_post_at', 'is_unread']).pipe( + return myUnreadChannels.pipe( combineLatestWith(channelsMap, notifyProps), map(filterAndSortMyChannels), combineLatestWith(lastUnread), diff --git a/app/utils/categories.ts b/app/utils/categories.ts index 61039dec84..2cd350f73b 100644 --- a/app/utils/categories.ts +++ b/app/utils/categories.ts @@ -1,6 +1,197 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {General, Preferences} from '@constants'; +import {DMS_CATEGORY} from '@constants/categories'; +import {getPreferenceAsBool, getPreferenceValue} from '@helpers/api/preference'; +import {isDMorGM} from '@utils/channel'; +import {getUserIdFromChannelName} from '@utils/user'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type PreferenceModel from '@typings/database/models/servers/preference'; +import type UserModel from '@typings/database/models/servers/user'; + +export type ChannelWithMyChannel = { + channel: ChannelModel; + myChannel: MyChannelModel; + sortOrder: number; +} + export function makeCategoryChannelId(teamId: string, channelId: string) { return `${teamId}_${channelId}`; } + +export const isUnreadChannel = (myChannel: MyChannelModel, notifyProps?: Partial, lastUnreadChannelId?: string) => { + const isMuted = notifyProps?.mark_unread === General.MENTION; + return (isMuted && myChannel.mentionsCount) || (!isMuted && myChannel.isUnread) || (myChannel.id === lastUnreadChannelId); +}; + +export const filterArchivedChannels = (channelsWithMyChannel: ChannelWithMyChannel[], currentChannelId: string) => { + return channelsWithMyChannel.filter((cwm) => cwm.channel.deleteAt === 0 || cwm.channel.id === currentChannelId); +}; + +export const filterAutoclosedDMs = ( + categoryType: CategoryType, limit: number, currentChannelId: string, + channelsWithMyChannel: ChannelWithMyChannel[], preferences: PreferenceModel[], + notifyPropsPerChannel: Record>, + deactivatedDMs?: Map, + lastUnreadChannelId?: string, +) => { + if (categoryType !== DMS_CATEGORY) { + // Only autoclose DMs that haven't been assigned to a category + return channelsWithMyChannel; + } + + const getLastViewedAt = (cwm: ChannelWithMyChannel) => { + // The server only ever sets the last_viewed_at to the time of the last post in channel, so we may need + // to use the preferences added for the previous version of autoclosing DMs. + const id = cwm.channel.id; + return Math.max( + cwm.myChannel.lastViewedAt, + getPreferenceValue(preferences, Preferences.CATEGORIES.CHANNEL_APPROXIMATE_VIEW_TIME, id, 0), + getPreferenceValue(preferences, Preferences.CATEGORIES.CHANNEL_OPEN_TIME, id, 0), + ); + }; + + let unreadCount = 0; + let visibleChannels = channelsWithMyChannel.filter((cwm) => { + const {channel, myChannel} = cwm; + if (myChannel.isUnread) { + unreadCount++; + + // Unread DMs/GMs are always visible + return true; + } + + if (channel.id === currentChannelId) { + return true; + } + + // 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); + if (teammate && teammate.deleteAt > lastViewedAt) { + return false; + } + } + + return true; + }); + + visibleChannels.sort((cwmA, cwmB) => { + const channelA = cwmA.channel; + const channelB = cwmB.channel; + const myChannelA = cwmA.myChannel; + const myChannelB = cwmB.myChannel; + + // Should always prioritise the current channel + if (channelA.id === currentChannelId) { + return -1; + } else if (channelB.id === currentChannelId) { + return 1; + } + + // Second priority is for unread channels + const isUnreadA = isUnreadChannel(myChannelA, notifyPropsPerChannel[myChannelA.id], lastUnreadChannelId); + const isUnreadB = isUnreadChannel(myChannelB, notifyPropsPerChannel[myChannelB.id], lastUnreadChannelId); + if (isUnreadA && !isUnreadB) { + return -1; + } else if (isUnreadB && !isUnreadA) { + return 1; + } + + // Third priority is last_viewed_at + const channelAlastViewed = getLastViewedAt(cwmA) || 0; + const channelBlastViewed = getLastViewedAt(cwmB) || 0; + + if (channelAlastViewed > channelBlastViewed) { + return -1; + } else if (channelBlastViewed > channelAlastViewed) { + return 1; + } + + return 0; + }); + + // The limit of DMs user specifies to be rendered in the sidebar + const remaining = Math.max(limit, unreadCount); + visibleChannels = visibleChannels.slice(0, remaining); + + return visibleChannels; +}; + +export const filterManuallyClosedDms = ( + channelsWithMyChannel: ChannelWithMyChannel[], + notifyPropsPerChannel: Record>, + preferences: PreferenceModel[], + currentUserId: string, + lastUnreadChannelId?: string, +) => { + return channelsWithMyChannel.filter((cwm) => { + const {channel, myChannel} = cwm; + + 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)) { + // Unread DMs/GMs are always visible + return true; + } + + if (channel.type === General.DM_CHANNEL) { + const teammateId = getUserIdFromChannelName(currentUserId, channel.name); + return getPreferenceAsBool(preferences, Preferences.CATEGORIES.DIRECT_CHANNEL_SHOW, teammateId, true); + } + + return getPreferenceAsBool(preferences, Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, channel.id, true); + }); +}; + +const sortChannelsByName = (notifyPropsPerChannel: Record>, locale: string) => { + return (a: ChannelWithMyChannel, b: ChannelWithMyChannel) => { + // Sort muted channels last + const aMuted = notifyPropsPerChannel[a.channel.id]?.mark_unread === General.MENTION; + const bMuted = notifyPropsPerChannel[b.channel.id]?.mark_unread === General.MENTION; + + if (aMuted && !bMuted) { + return 1; + } else if (!aMuted && bMuted) { + return -1; + } + + // And then sort alphabetically + return a.channel.displayName.localeCompare(b.channel.displayName, locale, {numeric: true}); + }; +}; + +export const sortChannels = (sorting: CategorySorting, channelsWithMyChannel: ChannelWithMyChannel[], notifyPropsPerChannel: Record>, locale: string) => { + if (sorting === 'recent') { + return channelsWithMyChannel.sort((cwmA, cwmB) => { + return cwmB.myChannel.lastPostAt - cwmA.myChannel.lastPostAt; + }).map((cwm) => cwm.channel); + } else if (sorting === 'manual') { + return channelsWithMyChannel.sort((cwmA, cwmB) => { + return cwmB.sortOrder - cwmA.sortOrder; + }).map((cwm) => cwm.channel); + } + + const sortByName = sortChannelsByName(notifyPropsPerChannel, locale); + return channelsWithMyChannel.sort(sortByName).map((cwm) => cwm.channel); +}; + +export const getUnreadIds = (cwms: ChannelWithMyChannel[], notifyPropsPerChannel: Record>, lastUnreadId?: string) => { + return cwms.reduce>((result, cwm) => { + if (isUnreadChannel(cwm.myChannel, notifyPropsPerChannel, lastUnreadId)) { + result.add(cwm.channel.id); + } + + return result; + }, new Set()); +}; diff --git a/types/database/models/servers/category.ts b/types/database/models/servers/category.ts index 1206c15875..9afea104bb 100644 --- a/types/database/models/servers/category.ts +++ b/types/database/models/servers/category.ts @@ -25,7 +25,7 @@ declare class CategoryModel extends Model { displayName: string; /** type : The type of category */ - type: string; + type: CategoryType; /** sort_order : The sort order for this category */ sortOrder: number;