diff --git a/app/constants/categories.ts b/app/constants/categories.ts index 0ded12dab..a8ae0612c 100644 --- a/app/constants/categories.ts +++ b/app/constants/categories.ts @@ -4,9 +4,11 @@ export const CHANNELS_CATEGORY = 'channels'; export const DMS_CATEGORY = 'direct_messages'; export const FAVORITES_CATEGORY = 'favorites'; +export const UNREADS_CATEGORY = 'unreads'; export default { CHANNELS_CATEGORY, DMS_CATEGORY, FAVORITES_CATEGORY, + UNREADS_CATEGORY, }; 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 deleted file mode 100644 index 6030c78aa..000000000 --- a/app/screens/home/channel_list/categories_list/categories/body/category_body.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Database, Q} from '@nozbe/watermelondb'; -import React from 'react'; - -import {MM_TABLES} from '@constants/database'; -import {DEFAULT_LOCALE} from '@i18n'; -import {renderWithEverything} from '@test/intl-test-helper'; -import TestHelper from '@test/test_helper'; - -import CategoryBody from '.'; - -import type CategoryModel from '@typings/database/models/servers/category'; - -const {SERVER: {CATEGORY}} = MM_TABLES; - -describe('components/channel_list/categories/body', () => { - let database: Database; - let category: CategoryModel; - - beforeAll(async () => { - const server = await TestHelper.setupServerDatabase(); - database = server.database; - - const categories = await database.get(CATEGORY).query( - Q.take(1), - ).fetch(); - - category = categories[0]; - }); - - it('should match snapshot', (done) => { - const wrapper = renderWithEverything( - undefined} - />, - {database}, - ); - - setTimeout(() => { - expect(wrapper.toJSON()).toBeTruthy(); - done(); - }); - }); -}); 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 deleted file mode 100644 index 597b2bf89..000000000 --- a/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {DeviceEventEmitter, FlatList} from 'react-native'; -import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; - -import {fetchDirectChannelsInfo} from '@actions/remote/channel'; -import ChannelItem from '@components/channel_item'; -import {ROW_HEIGHT as CHANNEL_ROW_HEIGHT} from '@components/channel_item/channel_item'; -import {Events} from '@constants'; -import {DRAFT, THREAD} from '@constants/screens'; -import {useServerUrl} from '@context/server'; -import {isDMorGM} from '@utils/channel'; - -import type CategoryModel from '@typings/database/models/servers/category'; -import type ChannelModel from '@typings/database/models/servers/channel'; - -type Props = { - sortedChannels: ChannelModel[]; - category: CategoryModel; - onChannelSwitch: (channel: Channel | ChannelModel) => void; - unreadIds: Set; - unreadsOnTop: boolean; -}; - -const extractKey = (item: ChannelModel) => item.id; - -const CategoryBody = ({sortedChannels, unreadIds, unreadsOnTop, category, onChannelSwitch}: Props) => { - const serverUrl = useServerUrl(); - const [isChannelScreenActive, setChannelScreenActive] = useState(true); - - useEffect(() => { - const listener = DeviceEventEmitter.addListener(Events.ACTIVE_SCREEN, (screen: string) => { - setChannelScreenActive(screen !== DRAFT && screen !== THREAD); - }); - - return () => { - listener.remove(); - }; - }, []); - - const ids = useMemo(() => { - const filteredChannels = unreadsOnTop ? sortedChannels.filter((c) => !unreadIds.has(c.id)) : sortedChannels; - - return filteredChannels; - }, [category.type, sortedChannels, unreadIds, unreadsOnTop]); - - const unreadChannels = useMemo(() => { - return unreadsOnTop ? [] : ids.filter((c) => unreadIds.has(c.id)); - }, [ids, unreadIds, unreadsOnTop]); - - const directChannels = useMemo(() => { - return ids.concat(unreadChannels).filter(isDMorGM); - }, [ids.length, unreadChannels.length]); - - const renderItem = useCallback(({item}: {item: ChannelModel}) => { - return ( - - ); - }, [category.displayName, isChannelScreenActive, onChannelSwitch]); - - const sharedValue = useSharedValue(category.collapsed); - - useEffect(() => { - sharedValue.value = category.collapsed; - }, [category.collapsed]); - - useEffect(() => { - if (directChannels.length) { - fetchDirectChannelsInfo(serverUrl, directChannels.filter((c) => !c.displayName)); - } - }, [directChannels.length]); - - const height = ids.length ? ids.length * CHANNEL_ROW_HEIGHT : 0; - const unreadHeight = unreadChannels.length ? unreadChannels.length * CHANNEL_ROW_HEIGHT : 0; - - const animatedStyle = useAnimatedStyle(() => { - const opacity = unreadHeight > 0 ? 1 : 0; - const heightDuration = unreadHeight > 0 ? 200 : 300; - return { - height: withTiming(sharedValue.value ? unreadHeight : height, {duration: heightDuration}), - opacity: withTiming(sharedValue.value ? opacity : 1, {duration: sharedValue.value ? 200 : 300, easing: Easing.inOut(Easing.exp)}), - }; - }, [height, unreadHeight]); - - const listStyle = useMemo(() => ({ - height: category.collapsed ? unreadHeight : height, - }), [category.collapsed, height, unreadHeight]); - - return ( - - - - ); -}; - -export default CategoryBody; 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 deleted file mode 100644 index f5dc64b9e..000000000 --- a/app/screens/home/channel_list/categories_list/categories/body/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {of as of$, Observable} from 'rxjs'; -import {switchMap, combineLatestWith, distinctUntilChanged} from 'rxjs/operators'; - -import {Preferences} from '@constants'; -import {DMS_CATEGORY} from '@constants/categories'; -import {getSidebarPreferenceAsBool} from '@helpers/api/preference'; -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'; - -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 EnhanceProps = { - category: CategoryModel; - locale: string; - currentUserId: string; - isTablet: boolean; -} & WithDatabaseArgs - -const withUserId = withObservables([], ({database}: WithDatabaseArgs) => ({currentUserId: observeCurrentUserId(database)})); - -const observeCategoryChannels = (category: CategoryModel, myChannels: Observable) => { - 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); - } - - return result; - }, [])); - }), - ); -}; - -const enhanced = withObservables([], ({category, currentUserId, database, isTablet, locale}: EnhanceProps) => { - const categoryMyChannels = category.myChannels.observeWithColumns(['last_post_at', 'is_unread']); - const channelsWithMyChannel = observeCategoryChannels(category, categoryMyChannels); - const currentChannelId = isTablet ? observeCurrentChannelId(database) : of$(''); - const lastUnreadId = isTablet ? observeLastUnreadChannelId(database) : of$(undefined); - - const unreadsOnTop = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS). - observeWithColumns(['value']). - pipe( - switchMap((prefs: PreferenceModel[]) => of$(getSidebarPreferenceAsBool(prefs, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS))), - ); - - let limit = of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT); - if (category.type === DMS_CATEGORY) { - limit = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_LIMIT_DMS). - observeWithColumns(['value']).pipe( - switchMap((val) => { - return val[0] ? of$(parseInt(val[0].value, 10)) : of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT); - }), - ); - } - - const notifyPropsPerChannel = categoryMyChannels.pipe( - // eslint-disable-next-line max-nested-callbacks - switchMap((mc) => observeNotifyPropsByChannels(database, mc)), - ); - - 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) ? 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, deactivatedUsers, maxDms]) => { - let channelsW = cwms; - - channelsW = filterArchivedChannels(channelsW, channelId); - channelsW = filterManuallyClosedDms(channelsW, notifyProps, manuallyClosedDms, currentUserId, unreadId); - channelsW = filterAutoclosedDMs(category.type, maxDms, currentUserId, channelId, channelsW, autoclose, notifyProps, deactivatedUsers, 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 { - category, - sortedChannels, - unreadIds, - unreadsOnTop, - }; -}); - -export default withDatabase(withUserId(enhanced(CategoryBody))); diff --git a/app/screens/home/channel_list/categories_list/categories/categories.test.tsx b/app/screens/home/channel_list/categories_list/categories/categories.test.tsx index bfacb314b..6a3b8dfd0 100644 --- a/app/screens/home/channel_list/categories_list/categories/categories.test.tsx +++ b/app/screens/home/channel_list/categories_list/categories/categories.test.tsx @@ -28,7 +28,7 @@ describe('components/channel_list/categories', () => { it('render without error', () => { const wrapper = renderWithEverything( - , + , {database}, ); @@ -49,12 +49,12 @@ describe('performance metrics', () => { }); it('properly send metric on load', () => { - renderWithEverything(, {database, serverUrl}); + renderWithEverything(, {database, serverUrl}); expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_team_switch', serverUrl); }); it('properly call again after switching teams', async () => { - renderWithEverything(, {database, serverUrl}); + renderWithEverything(, {database, serverUrl}); expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(1); act(() => { DeviceEventEmitter.emit(Events.TEAM_SWITCH, true); diff --git a/app/screens/home/channel_list/categories_list/categories/categories.tsx b/app/screens/home/channel_list/categories_list/categories/categories.tsx index b696e0beb..b5bbf260f 100644 --- a/app/screens/home/channel_list/categories_list/categories/categories.tsx +++ b/app/screens/home/channel_list/categories_list/categories/categories.tsx @@ -1,34 +1,50 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {FlashList, type ListRenderItem, type ViewToken} from '@shopify/flash-list'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {useIntl} from 'react-intl'; -import {DeviceEventEmitter, FlatList, StyleSheet, View} from 'react-native'; +import {DeviceEventEmitter, View, type LayoutChangeEvent} from 'react-native'; -import {switchToChannelById} from '@actions/remote/channel'; +import {fetchDirectChannelsInfo, switchToChannelById} from '@actions/remote/channel'; +import ChannelItem from '@components/channel_item'; +import {ROW_HEIGHT as CHANNEL_ROW_HEIGHT} from '@components/channel_item/channel_item'; +import FormattedText from '@components/formatted_text'; import Loading from '@components/loading'; import {Events} from '@constants'; -import {CHANNEL} from '@constants/screens'; +import {UNREADS_CATEGORY} from '@constants/categories'; +import {CHANNEL, DRAFT, THREAD} from '@constants/screens'; +import {HOME_PADDING} from '@constants/view'; import {useServerUrl} from '@context/server'; -import {useIsTablet} from '@hooks/device'; +import {useTheme} from '@context/theme'; import {useTeamSwitch} from '@hooks/team_switch'; import PerformanceMetricsManager from '@managers/performance_metrics_manager'; +import {isDMorGM} from '@utils/channel'; +import {logDebug} from '@utils/log'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; -import CategoryBody from './body'; +import Empty from './empty_unreads'; import LoadCategoriesError from './error'; import CategoryHeader from './header'; -import UnreadCategories from './unreads'; +import {keyExtractor, getItemType, type FlattenedItem} from './helpers/flatten_categories'; -import type CategoryModel from '@typings/database/models/servers/category'; import type ChannelModel from '@typings/database/models/servers/channel'; type Props = { - categories: CategoryModel[]; + flattenedItems: FlattenedItem[]; + unreadChannelIds: Set; onlyUnreads: boolean; - unreadsOnTop: boolean; -} + isTablet: boolean; +}; -const styles = StyleSheet.create({ +const HEADER_HEIGHT = 44; +const ESTIMATED_ITEM_SIZE = 42; +const VIEWABILITY_CONFIG = { + itemVisiblePercentThreshold: 50, + minimumViewTime: 300, +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ mainList: { flex: 1, }, @@ -37,38 +53,50 @@ const styles = StyleSheet.create({ justifyContent: 'center', flex: 1, }, -}); + unreadsHeading: { + color: changeOpacity(theme.sidebarText, 0.64), + ...typography('Heading', 75), + textTransform: 'uppercase', + paddingVertical: 8, + marginTop: 12, + ...HOME_PADDING, + }, +})); -const extractKey = (item: CategoryModel | 'UNREADS') => (item === 'UNREADS' ? 'UNREADS' : item.id); - -const Categories = ({ - categories, - onlyUnreads, - unreadsOnTop, -}: Props) => { - const intl = useIntl(); - const listRef = useRef(null); +const Categories = ({flattenedItems, unreadChannelIds, onlyUnreads, isTablet}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + const listRef = useRef>(null); const serverUrl = useServerUrl(); - const isTablet = useIsTablet(); const switchingTeam = useTeamSwitch(); - const teamId = categories[0]?.teamId; - const showOnlyUnreadsCategory = onlyUnreads && !unreadsOnTop; + const [initialLoad, setInitialLoad] = useState(flattenedItems.length === 0); + const [isChannelScreenActive, setChannelScreenActive] = useState(true); + const [listHeight, setListHeight] = useState(0); + const [hasUnreadsAbove, setHasUnreadsAbove] = useState(false); + const [hasUnreadsBelow, setHasUnreadsBelow] = useState(false); - const categoriesToShow = useMemo(() => { - if (showOnlyUnreadsCategory) { - return ['UNREADS' as const]; + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.ACTIVE_SCREEN, (screen: string) => { + setChannelScreenActive(screen !== DRAFT && screen !== THREAD); + }); + + return () => { + listener.remove(); + }; + }, []); + + const directChannels = useMemo(() => { + const channels = flattenedItems.filter((item) => item.type === 'channel' && isDMorGM(item.channel)); + const channelModels = channels.map((item) => (item.type === 'channel' ? item.channel : null)); + return channelModels.filter((c): c is ChannelModel => c !== null); + }, [flattenedItems]); + + useEffect(() => { + const channelsWithoutDisplayName = directChannels.filter((c) => !c.displayName); + if (channelsWithoutDisplayName.length) { + fetchDirectChannelsInfo(serverUrl, channelsWithoutDisplayName); } - - const orderedCategories = [...categories]; - orderedCategories.sort((a, b) => a.sortOrder - b.sortOrder); - - if (unreadsOnTop) { - return ['UNREADS' as const, ...orderedCategories]; - } - return orderedCategories; - }, [categories, unreadsOnTop, showOnlyUnreadsCategory]); - - const [initiaLoad, setInitialLoad] = useState(!categoriesToShow.length); + }, [directChannels, serverUrl]); const onChannelSwitch = useCallback(async (c: Channel | ChannelModel) => { DeviceEventEmitter.emit(Events.ACTIVE_SCREEN, CHANNEL); @@ -76,29 +104,100 @@ const Categories = ({ switchToChannelById(serverUrl, c.id); }, [serverUrl]); - const renderCategory = useCallback((data: {item: CategoryModel | 'UNREADS'}) => { - if (data.item === 'UNREADS') { + const renderItem: ListRenderItem = useCallback(({item}) => { + if (item.type === 'unreads_header') { return ( - ); } + + if (item.type === 'header') { + return ; + } + + // item.type === 'channel' + const testIdSuffix = item.categoryId === UNREADS_CATEGORY ? UNREADS_CATEGORY : item.channelId; return ( - <> - - - + ); - }, [teamId, intl.locale, isTablet, onChannelSwitch, showOnlyUnreadsCategory]); + }, [styles, onChannelSwitch, isChannelScreenActive]); + + const overrideItemLayout = useCallback(( + layout: {span?: number; size?: number}, + item: FlattenedItem, + ) => { + if (item.type === 'unreads_header' || item.type === 'header') { + layout.size = HEADER_HEIGHT; + } else { + layout.size = CHANNEL_ROW_HEIGHT; + } + layout.span = 1; + }, []); + + const onListLayout = useCallback((event: LayoutChangeEvent) => { + const {height} = event.nativeEvent.layout; + setListHeight(height); + }, []); + + const onViewableItemsChanged = useCallback(({viewableItems}: {viewableItems: ViewToken[]}) => { + if (!viewableItems.length || !unreadChannelIds.size) { + setHasUnreadsAbove(false); + setHasUnreadsBelow(false); + return; + } + + // Get indices of viewable items + const visibleIndices = viewableItems. + filter((item) => item.isViewable && item.index !== null). + map((item) => item.index as number); + + if (!visibleIndices.length) { + return; + } + + const firstVisible = Math.min(...visibleIndices); + const lastVisible = Math.max(...visibleIndices); + + // Single pass: check channels above and below viewport + let hasUnreadsAboveViewport = false; + let hasUnreadsBelowViewport = false; + + for (let i = 0; i < flattenedItems.length; i++) { + const item = flattenedItems[i]; + + if (item.type === 'channel' && unreadChannelIds.has(item.channelId)) { + if (i < firstVisible) { + hasUnreadsAboveViewport = true; + } else if (i > lastVisible) { + hasUnreadsBelowViewport = true; + } + + // Early exit if we found both + if (hasUnreadsAboveViewport && hasUnreadsBelowViewport) { + break; + } + } + } + + setHasUnreadsAbove(hasUnreadsAboveViewport); + setHasUnreadsBelow(hasUnreadsBelowViewport); + }, [flattenedItems, unreadChannelIds]); + + useEffect(() => { + // Once we add the components to show unreads above/below, this useEffect can be removed + logDebug('Unreads above viewport:', hasUnreadsAbove, ', below viewport:', hasUnreadsBelow); + }, [hasUnreadsAbove, hasUnreadsBelow]); useEffect(() => { const t = setTimeout(() => { @@ -114,40 +213,52 @@ const Categories = ({ } PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl); - }, [switchingTeam]); + }, [switchingTeam, serverUrl]); - if (!categories.length) { + useEffect(() => { + if (listRef.current) { + listRef.current.recomputeViewableItems(); + } + }, []); + + const showEmptyState = onlyUnreads && flattenedItems.length === 0 && !isTablet; + + const ListEmptyComponent = useMemo(() => { + if (showEmptyState) { + return ( + + + + ); + } + return undefined; + }, [showEmptyState, listHeight]); + + if (flattenedItems.length === 0 && !switchingTeam && !initialLoad && !onlyUnreads) { return ; } return ( <> - {!switchingTeam && !initiaLoad && showOnlyUnreadsCategory && - - - - } - {!switchingTeam && !initiaLoad && !showOnlyUnreadsCategory && ( - ref={listRef} - renderItem={renderCategory} - style={styles.mainList} + data={flattenedItems} + renderItem={renderItem} + keyExtractor={keyExtractor} + getItemType={getItemType} + estimatedItemSize={ESTIMATED_ITEM_SIZE} + overrideItemLayout={overrideItemLayout} + drawDistance={ESTIMATED_ITEM_SIZE * 20} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} - keyExtractor={extractKey} - initialNumToRender={categoriesToShow.length} - - // @ts-expect-error strictMode not included in the types - strictMode={true} + ListEmptyComponent={ListEmptyComponent} + onLayout={onListLayout} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={VIEWABILITY_CONFIG} /> )} - {(switchingTeam || initiaLoad) && ( + {(switchingTeam || initialLoad) && ( ({ buttonContainer: { @@ -44,14 +40,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -function EmptyUnreads({onlyUnreads}: Props) { +function EmptyUnreads() { const intl = useIntl(); const theme = useTheme(); const serverUrl = useServerUrl(); const styles = getStyleSheet(theme); const onPress = () => { - showUnreadChannelsOnly(serverUrl, !onlyUnreads); + showUnreadChannelsOnly(serverUrl, false); }; return ( diff --git a/app/screens/home/channel_list/categories_list/categories/helpers/flatten_categories.test.ts b/app/screens/home/channel_list/categories_list/categories/helpers/flatten_categories.test.ts new file mode 100644 index 000000000..f9bd42144 --- /dev/null +++ b/app/screens/home/channel_list/categories_list/categories/helpers/flatten_categories.test.ts @@ -0,0 +1,290 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {UNREADS_CATEGORY} from '@constants/categories'; +import TestHelper from '@test/test_helper'; + +import {keyExtractor, getItemType, flattenCategories, type CategoryData, type FlattenedItem} from './flatten_categories'; + +import type CategoryModel from '@typings/database/models/servers/category'; +import type ChannelModel from '@typings/database/models/servers/channel'; + +describe('flatten_categories utils', () => { + // Create mock categories using TestHelper + const mockCategory1: CategoryModel = TestHelper.fakeCategoryModel({ + id: 'cat1', + collapsed: false, + }); + + const mockCategory2: CategoryModel = TestHelper.fakeCategoryModel({ + id: 'cat2', + collapsed: true, + }); + + // Create mock channels using TestHelper + const mockChannel1: ChannelModel = TestHelper.fakeChannelModel({id: 'channel1'}); + const mockChannel2: ChannelModel = TestHelper.fakeChannelModel({id: 'channel2'}); + const mockChannel3: ChannelModel = TestHelper.fakeChannelModel({id: 'channel3'}); + + describe('keyExtractor', () => { + it('should return "unreads_header" for unreads_header type', () => { + const item: FlattenedItem = {type: 'unreads_header'}; + expect(keyExtractor(item)).toBe('unreads_header'); + }); + + it('should return "h:{categoryId}" for header type', () => { + const item: FlattenedItem = { + type: 'header', + categoryId: 'cat1', + category: mockCategory1, + }; + expect(keyExtractor(item)).toBe('h:cat1'); + }); + + it('should return "c:{channelId}" for channel type', () => { + const item: FlattenedItem = { + type: 'channel', + categoryId: 'cat1', + channelId: 'channel1', + channel: mockChannel1, + }; + expect(keyExtractor(item)).toBe('c:channel1'); + }); + }); + + describe('getItemType', () => { + it('should return "unreads_header" for unreads_header item', () => { + const item: FlattenedItem = {type: 'unreads_header'}; + expect(getItemType(item)).toBe('unreads_header'); + }); + + it('should return "header" for header item', () => { + const item: FlattenedItem = { + type: 'header', + categoryId: 'cat1', + category: mockCategory1, + }; + expect(getItemType(item)).toBe('header'); + }); + + it('should return "channel" for channel item', () => { + const item: FlattenedItem = { + type: 'channel', + categoryId: 'cat1', + channelId: 'channel1', + channel: mockChannel1, + }; + expect(getItemType(item)).toBe('channel'); + }); + }); + + describe('flattenCategories', () => { + describe('without unreadsOnTop', () => { + it('should flatten categories with headers and channels', () => { + const categoriesData: CategoryData[] = [{ + category: mockCategory1, + sortedChannels: [mockChannel1, mockChannel2], + unreadIds: new Set(['channel1']), + allUnreadChannels: [mockChannel1], + }]; + + const result = flattenCategories(categoriesData, false); + + expect(result).toHaveLength(3); // 1 header + 2 channels + expect(result[0].type).toBe('header'); + expect(result[1].type).toBe('channel'); + expect(result[2].type).toBe('channel'); + + const headerItem = result[0] as Extract; + expect(headerItem.categoryId).toBe('cat1'); + + const channelItem1 = result[1] as Extract; + expect(channelItem1.channelId).toBe('channel1'); + expect(channelItem1.categoryId).toBe('cat1'); + + const channelItem2 = result[2] as Extract; + expect(channelItem2.channelId).toBe('channel2'); + expect(channelItem2.categoryId).toBe('cat1'); + }); + + it('should respect collapsed state - showing only unread channels', () => { + const categoriesData: CategoryData[] = [{ + category: mockCategory2, // collapsed = true + sortedChannels: [mockChannel1, mockChannel2, mockChannel3], + unreadIds: new Set(['channel1', 'channel3']), + allUnreadChannels: [mockChannel1, mockChannel3], + }]; + + const result = flattenCategories(categoriesData, false); + + expect(result).toHaveLength(3); // 1 header + 2 unread channels only + expect(result[0].type).toBe('header'); + expect(result[1].type).toBe('channel'); + expect(result[2].type).toBe('channel'); + + const channelItem1 = result[1] as Extract; + expect(channelItem1.channelId).toBe('channel1'); + + const channelItem2 = result[2] as Extract; + expect(channelItem2.channelId).toBe('channel3'); + }); + + it('should handle empty categories', () => { + const categoriesData: CategoryData[] = [{ + category: mockCategory1, + sortedChannels: [], + unreadIds: new Set(), + allUnreadChannels: [], + }]; + + const result = flattenCategories(categoriesData, false); + + expect(result).toHaveLength(1); // Only header + expect(result[0].type).toBe('header'); + }); + + it('should handle multiple categories', () => { + const categoriesData: CategoryData[] = [ + { + category: mockCategory1, + sortedChannels: [mockChannel1], + unreadIds: new Set(), + allUnreadChannels: [], + }, + { + category: mockCategory2, + sortedChannels: [mockChannel2], + unreadIds: new Set(['channel2']), + allUnreadChannels: [mockChannel2], + }, + ]; + + const result = flattenCategories(categoriesData, false); + + expect(result).toHaveLength(4); // 2 headers + 2 channels + expect(result[0].type).toBe('header'); + expect(result[1].type).toBe('channel'); + expect(result[2].type).toBe('header'); + expect(result[3].type).toBe('channel'); + }); + }); + + describe('with unreadsOnTop', () => { + it('should create unreads_header and add all unread channels', () => { + const categoriesData: CategoryData[] = [ + { + category: mockCategory1, + sortedChannels: [mockChannel1, mockChannel2], + unreadIds: new Set(['channel1']), + allUnreadChannels: [mockChannel1], + }, + { + category: mockCategory2, + sortedChannels: [mockChannel3], + unreadIds: new Set(['channel3']), + allUnreadChannels: [mockChannel3], + }, + ]; + + const result = flattenCategories(categoriesData, true); + + // unreads_header + 2 unread channels + 2 category headers + remaining channels + expect(result[0].type).toBe('unreads_header'); + expect(result[1].type).toBe('channel'); + expect(result[2].type).toBe('channel'); + + const unreadChannel1 = result[1] as Extract; + expect(unreadChannel1.channelId).toBe('channel1'); + expect(unreadChannel1.categoryId).toBe(UNREADS_CATEGORY); + + const unreadChannel2 = result[2] as Extract; + expect(unreadChannel2.channelId).toBe('channel3'); + expect(unreadChannel2.categoryId).toBe(UNREADS_CATEGORY); + }); + + it('should deduplicate unread channels across categories', () => { + const categoriesData: CategoryData[] = [ + { + category: mockCategory1, + sortedChannels: [mockChannel1], + unreadIds: new Set(['channel1']), + allUnreadChannels: [mockChannel1], + }, + { + category: mockCategory2, + sortedChannels: [mockChannel1], // Same channel in different category + unreadIds: new Set(['channel1']), + allUnreadChannels: [mockChannel1], + }, + ]; + + const result = flattenCategories(categoriesData, true); + + // Count how many times channel1 appears in unreads section + const unreadsSection = result.slice(0, result.findIndex((item) => item.type === 'header')); + const channel1Count = unreadsSection.filter( + (item) => item.type === 'channel' && (item as any).channelId === 'channel1', + ).length; + + expect(channel1Count).toBe(1); // Should only appear once + }); + + it('should not add unreads_header when no unreads exist', () => { + const categoriesData: CategoryData[] = [{ + category: mockCategory1, + sortedChannels: [mockChannel1], + unreadIds: new Set(), + allUnreadChannels: [], + }]; + + const result = flattenCategories(categoriesData, true); + + expect(result[0].type).toBe('header'); // Should start with category header, not unreads_header + expect(result.find((item) => item.type === 'unreads_header')).toBeUndefined(); + }); + + it('should still show category headers and channels below unreads', () => { + const categoriesData: CategoryData[] = [{ + category: mockCategory1, + sortedChannels: [mockChannel1, mockChannel2], + unreadIds: new Set(['channel1']), + allUnreadChannels: [mockChannel1], + }]; + + const result = flattenCategories(categoriesData, true); + + // Find category header position + const categoryHeaderIndex = result.findIndex( + (item) => item.type === 'header' && (item as any).categoryId === 'cat1', + ); + + expect(categoryHeaderIndex).toBeGreaterThan(0); // Should exist after unreads section + expect(result[categoryHeaderIndex].type).toBe('header'); + }); + + it('should respect collapsed state in normal categories section', () => { + const categoriesData: CategoryData[] = [{ + category: mockCategory2, // collapsed = true + sortedChannels: [mockChannel1, mockChannel2], + unreadIds: new Set(['channel1']), + allUnreadChannels: [mockChannel1], + }]; + + const result = flattenCategories(categoriesData, true); + + // Find category header + const categoryHeaderIndex = result.findIndex( + (item) => item.type === 'header' && (item as any).categoryId === 'cat2', + ); + + // Count channels after category header (before next header or end) + const channelsAfterHeader = result.slice(categoryHeaderIndex + 1).filter( + (item) => item.type === 'channel', + ).length; + + // Since collapsed and only channel1 is unread, it should show only unread channels + expect(channelsAfterHeader).toBe(1); + }); + }); + }); +}); diff --git a/app/screens/home/channel_list/categories_list/categories/helpers/flatten_categories.ts b/app/screens/home/channel_list/categories_list/categories/helpers/flatten_categories.ts new file mode 100644 index 000000000..df15ffc40 --- /dev/null +++ b/app/screens/home/channel_list/categories_list/categories/helpers/flatten_categories.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {UNREADS_CATEGORY} from '@constants/categories'; + +import type CategoryModel from '@typings/database/models/servers/category'; +import type ChannelModel from '@typings/database/models/servers/channel'; + +export type FlattenedItem = + | {type: 'unreads_header'} + | {type: 'header'; categoryId: string; category: CategoryModel} + | {type: 'channel'; categoryId: string; channelId: string; channel: ChannelModel}; + +export type CategoryData = { + category: CategoryModel; + sortedChannels: ChannelModel[]; + unreadIds: Set; + allUnreadChannels: ChannelModel[]; +}; + +export const keyExtractor = (item: FlattenedItem): string => { + if (item.type === 'unreads_header') { + return 'unreads_header'; + } + return item.type === 'header' ? `h:${item.categoryId}` : `c:${item.channelId}`; +}; + +export const getItemType = (item: FlattenedItem): 'unreads_header' | 'header' | 'channel' => { + return item.type; +}; + +export const flattenCategories = ( + categoriesData: CategoryData[], + unreadsOnTop: boolean, +): FlattenedItem[] => { + const result: FlattenedItem[] = []; + + if (unreadsOnTop) { + const allUnreadChannels: ChannelModel[] = []; + const seenChannelIds = new Set(); + + for (const {allUnreadChannels: categoryUnreads} of categoriesData) { + for (const channel of categoryUnreads) { + if (!seenChannelIds.has(channel.id)) { + seenChannelIds.add(channel.id); + allUnreadChannels.push(channel); + } + } + } + + if (allUnreadChannels.length > 0) { + result.push({type: 'unreads_header'}); + + for (const channel of allUnreadChannels) { + result.push({ + type: 'channel', + categoryId: UNREADS_CATEGORY, + channelId: channel.id, + channel, + }); + } + } + } + + for (const {category, sortedChannels, unreadIds} of categoriesData) { + result.push({ + type: 'header', + categoryId: category.id, + category, + }); + + const channelsToShow = category.collapsed? sortedChannels.filter((channel) => unreadIds.has(channel.id)): sortedChannels; + + for (const channel of channelsToShow) { + result.push({ + type: 'channel', + categoryId: category.id, + channelId: channel.id, + channel, + }); + } + } + + return result; +}; diff --git a/app/screens/home/channel_list/categories_list/categories/helpers/observe_flattened_categories.ts b/app/screens/home/channel_list/categories_list/categories/helpers/observe_flattened_categories.ts new file mode 100644 index 000000000..abbe278ef --- /dev/null +++ b/app/screens/home/channel_list/categories_list/categories/helpers/observe_flattened_categories.ts @@ -0,0 +1,308 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {of as of$, combineLatest, type Observable} from 'rxjs'; +import {switchMap, map, distinctUntilChanged} from 'rxjs/operators'; + +import {Preferences} from '@constants'; +import {DMS_CATEGORY, UNREADS_CATEGORY} from '@constants/categories'; +import {getSidebarPreferenceAsBool} from '@helpers/api/preference'; +import {filterAndSortMyChannels, makeChannelsMap} from '@helpers/database'; +import {queryCategoriesByTeamIds} from '@queries/servers/categories'; +import {getChannelById, observeChannelsByLastPostAt, observeNotifyPropsByChannels, queryMyChannelUnreads} from '@queries/servers/channel'; +import {queryPreferencesByCategoryAndName, querySidebarPreferences} from '@queries/servers/preference'; +import {observeCurrentChannelId, observeLastUnreadChannelId} from '@queries/servers/system'; +import {observeDeactivatedUsers} from '@queries/servers/user'; +import { + type ChannelWithMyChannel, + filterArchivedChannels, + filterAutoclosedDMs, + filterManuallyClosedDms, + getUnreadIds, + sortChannels, +} from '@utils/categories'; + +import {flattenCategories, type CategoryData, type FlattenedItem} from './flatten_categories'; + +import type Database from '@nozbe/watermelondb/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'; + +const filterUnreads = (channels: ChannelModel[], unreadIds: Set) => { + return channels.filter((c) => !unreadIds.has(c.id)); +}; + +const observeCategoryChannels = (category: CategoryModel, myChannels: Observable) => { + const channels = category.channels.observeWithColumns(['create_at', 'display_name']); + const manualSort = category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']); + return myChannels.pipe( + switchMap((my) => combineLatest([of$(my), channels, manualSort])), + map(([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 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); + } + return result; + }, []); + }), + ); +}; + +const observeCategoryData = ( + category: CategoryModel, + database: Database, + currentUserId: string, + locale: string, + isTablet: boolean, +): Observable => { + const categoryMyChannels = category.myChannels.observeWithColumns(['last_post_at', 'is_unread']); + const channelsWithMyChannel = observeCategoryChannels(category, categoryMyChannels); + const currentChannelId = isTablet ? observeCurrentChannelId(database) : of$(''); + const lastUnreadId = isTablet ? observeLastUnreadChannelId(database) : of$(undefined); + + let limit = of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT); + if (category.type === DMS_CATEGORY) { + limit = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_LIMIT_DMS). + observeWithColumns(['value']).pipe( + switchMap((val) => { + return val[0] ? of$(parseInt(val[0].value, 10)) : of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT); + }), + ); + } + + const notifyPropsPerChannel = categoryMyChannels.pipe( + switchMap((mc) => observeNotifyPropsByChannels(database, mc)), + ); + + 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( + switchMap((dms) => combineLatest([of$(dms), hiddenGmPrefs])), + map(([dms, gms]) => 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( + switchMap((viewTimes) => combineLatest([of$(viewTimes), openTimePrefs])), + map(([viewTimes, openTimes]) => viewTimes.concat(openTimes)), + ); + + // Observe category changes (especially collapsed state and sorting) + const categoryObservable = category.observe().pipe( + map((c) => ({sorting: c.sorting, collapsed: c.collapsed, type: c.type})), + distinctUntilChanged((a, b) => a.sorting === b.sorting && a.collapsed === b.collapsed && a.type === b.type), + ); + + const deactivated = (category.type === DMS_CATEGORY) ? observeDeactivatedUsers(database) : of$(undefined); + + return combineLatest([ + channelsWithMyChannel, + categoryObservable, + currentChannelId, + lastUnreadId, + notifyPropsPerChannel, + manuallyClosedPrefs, + autoclosePrefs, + deactivated, + limit, + ]).pipe( + map(([cwms, catData, channelId, unreadId, notifyProps, manuallyClosedDms, autoclose, deactivatedUsers, maxDms]) => { + let channelsW = cwms; + channelsW = filterArchivedChannels(channelsW, channelId); + channelsW = filterManuallyClosedDms(channelsW, notifyProps, manuallyClosedDms, currentUserId, unreadId); + channelsW = filterAutoclosedDMs(catData.type, maxDms, currentUserId, channelId, channelsW, autoclose, notifyProps, deactivatedUsers, unreadId); + + const sortedChannels = sortChannels(catData.sorting, channelsW, notifyProps, locale); + const unreadIds = getUnreadIds(cwms, notifyProps, unreadId); + const allUnreadChannels = sortedChannels.filter((c) => unreadIds.has(c.id)); + + return { + category, + sortedChannels, + unreadIds, + allUnreadChannels, + }; + }), + ); +}; + +export type FlattenedCategoriesData = { + items: FlattenedItem[]; + unreadChannelIds: Set; +}; + +// Observable for onlyUnreads mode - flat list of unread channels sorted by lastPostAt +const observeFlattenedUnreads = ( + database: Database, + currentTeamId: string, + isTablet: boolean, +): Observable => { + const getC = (lastUnreadChannelId: string) => getChannelById(database, lastUnreadChannelId); + + const lastUnread = isTablet ? observeLastUnreadChannelId(database).pipe( + switchMap(getC), + ) : of$(undefined); + + const myUnreadChannels = queryMyChannelUnreads(database, currentTeamId).observeWithColumns(['last_post_at', 'is_unread']); + const notifyProps = myUnreadChannels.pipe(switchMap((cs) => observeNotifyPropsByChannels(database, cs))); + const channels = myUnreadChannels.pipe(switchMap((myChannels) => observeChannelsByLastPostAt(database, myChannels))); + const channelsMap = channels.pipe(switchMap((cs) => of$(makeChannelsMap(cs)))); + + return combineLatest([myUnreadChannels, channelsMap, notifyProps, lastUnread]).pipe( + map(([myChannels, chMap, nProps, lastUnreadChannel]) => { + const filtered = filterAndSortMyChannels([myChannels, chMap, nProps]); + + let sortedChannels = filtered; + if (lastUnreadChannel && isTablet) { + sortedChannels = filtered.filter((c) => c && c.id !== lastUnreadChannel.id); + sortedChannels.unshift(lastUnreadChannel); + } + + const items: FlattenedItem[] = sortedChannels. + filter((c): c is ChannelModel => c !== null). + map((channel) => ({ + type: 'channel', + categoryId: UNREADS_CATEGORY, + channelId: channel.id, + channel, + })); + + const unreadChannelIds = new Set(); + for (const item of items) { + if (item.type === 'channel') { + unreadChannelIds.add(item.channelId); + } + } + + return {items, unreadChannelIds}; + }), + ); +}; + +// Observable for normal mode - categories with headers and grouped channels +const observeFlattenedCategoriesNormal = ( + categories: CategoryModel[], + database: Database, + currentUserId: string, + locale: string, + isTablet: boolean, +): Observable => { + if (categories.length === 0) { + return of$({items: [], unreadChannelIds: new Set()}); + } + + const unreadsOnTop = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS). + observeWithColumns(['value']). + pipe( + switchMap((prefs: PreferenceModel[]) => of$(getSidebarPreferenceAsBool(prefs, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS))), + ); + + const categoryDataObservables = categories.map((category) => + observeCategoryData(category, database, currentUserId, locale, isTablet), + ); + + return combineLatest([combineLatest(categoryDataObservables), unreadsOnTop]).pipe( + map(([categoriesData, unreadsOnTopValue]) => { + // Collect all unread channel IDs and process categories in one pass + const unreadChannelIds = new Set(); + const processedCategoriesData: CategoryData[] = categoriesData.map((catData) => { + // Add all unread channels from this category to the set + for (const channel of catData.allUnreadChannels) { + unreadChannelIds.add(channel.id); + } + + if (unreadsOnTopValue) { + return { + ...catData, + sortedChannels: filterUnreads(catData.sortedChannels, catData.unreadIds), + }; + } + return catData; + }); + + const items = flattenCategories(processedCategoriesData, unreadsOnTopValue); + return {items, unreadChannelIds}; + }), + distinctUntilChanged((prev, curr) => { + if (prev.items.length !== curr.items.length) { + return false; + } + + for (let i = 0; i < prev.items.length; i++) { + const prevItem = prev.items[i]; + const currItem = curr.items[i]; + + if (prevItem.type !== currItem.type) { + return false; + } + + if (prevItem.type === 'unreads_header') { + continue; + } + + if (prevItem.type === 'header' && currItem.type === 'header') { + if (prevItem.categoryId !== currItem.categoryId) { + return false; + } + } else if (prevItem.type === 'channel' && currItem.type === 'channel') { + if (prevItem.channelId !== currItem.channelId) { + return false; + } + } + } + + // Check if unread IDs changed + if (prev.unreadChannelIds.size !== curr.unreadChannelIds.size) { + return false; + } + + for (const id of prev.unreadChannelIds) { + if (!curr.unreadChannelIds.has(id)) { + return false; + } + } + + return true; + }), + ); +}; + +const sortCategories = (categories: CategoryModel[]) => { + return categories.sort((a, b) => a.sortOrder - b.sortOrder); +}; + +// Routes to the appropriate observable based on onlyUnreads mode +export const observeFlattenedCategories = ( + database: Database, + currentUserId: string, + locale: string, + isTablet: boolean, + onlyUnreads: boolean, + currentTeamId: string, +): Observable => { + if (onlyUnreads) { + return observeFlattenedUnreads(database, currentTeamId, isTablet); + } + + // Observe categories for the current team + const categories = queryCategoriesByTeamIds(database, [currentTeamId]).observeWithColumns(['sort_order', 'collapsed']); + + return categories.pipe( + switchMap((cats) => observeFlattenedCategoriesNormal(sortCategories(cats), database, currentUserId, locale, isTablet)), + ); +}; diff --git a/app/screens/home/channel_list/categories_list/categories/index.ts b/app/screens/home/channel_list/categories_list/categories/index.ts index 7c96b054d..1fd194045 100644 --- a/app/screens/home/channel_list/categories_list/categories/index.ts +++ b/app/screens/home/channel_list/categories_list/categories/index.ts @@ -2,49 +2,49 @@ // See LICENSE.txt for license information. import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {of as of$} from 'rxjs'; -import {switchMap, combineLatestWith} from 'rxjs/operators'; +import {switchMap, combineLatestWith, map} from 'rxjs/operators'; -import {Preferences} from '@constants'; -import {getPreferenceValue} from '@helpers/api/preference'; -import {queryCategoriesByTeamIds} from '@queries/servers/categories'; -import {querySidebarPreferences} from '@queries/servers/preference'; -import {observeConfigBooleanValue, observeCurrentTeamId, observeOnlyUnreads} from '@queries/servers/system'; +import {DEFAULT_LOCALE} from '@i18n'; +import {observeCurrentTeamId, observeOnlyUnreads} from '@queries/servers/system'; +import {observeCurrentUser} from '@queries/servers/user'; import Categories from './categories'; +import {observeFlattenedCategories} from './helpers/observe_flattened_categories'; import type {WithDatabaseArgs} from '@typings/database/database'; -import type PreferenceModel from '@typings/database/models/servers/preference'; -const enhanced = withObservables( - [], - ({database}: WithDatabaseArgs) => { - const currentTeamId = observeCurrentTeamId(database); - const categories = currentTeamId.pipe(switchMap((ctid) => queryCategoriesByTeamIds(database, [ctid]).observeWithColumns(['sort_order']))); +type Props = WithDatabaseArgs & { + isTablet: boolean; +}; - const unreadsOnTopUserPreference = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS). - observeWithColumns(['value']). - pipe( - switchMap((prefs: PreferenceModel[]) => of$(getPreferenceValue(prefs, Preferences.CATEGORIES.SIDEBAR_SETTINGS, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS))), +const enhanced = withObservables(['isTablet'], ({database, isTablet}: Props) => { + const currentTeamId = observeCurrentTeamId(database); + const currentUser = observeCurrentUser(database); + const onlyUnreads = observeOnlyUnreads(database); + + const categoriesData = currentUser.pipe( + combineLatestWith(onlyUnreads, currentTeamId), + switchMap(([user, isOnlyUnreads, teamId]) => { + return observeFlattenedCategories( + database, + user?.id || '', + user?.locale || DEFAULT_LOCALE, + isTablet, + isOnlyUnreads, + teamId, ); + }), + ); - const unreadsOnTopServerPreference = observeConfigBooleanValue(database, 'ExperimentalGroupUnreadChannels'); + const flattenedItems = categoriesData.pipe(map((data) => data.items)); + const unreadChannelIds = categoriesData.pipe(map((data) => data.unreadChannelIds)); - const unreadsOnTop = unreadsOnTopServerPreference.pipe( - combineLatestWith(unreadsOnTopUserPreference), - switchMap(([s, u]) => { - if (!u) { - return of$(s); - } - - return of$(u !== 'false'); - }), - ); - return { - categories, - onlyUnreads: observeOnlyUnreads(database), - unreadsOnTop, - }; - }); + return { + flattenedItems, + unreadChannelIds, + onlyUnreads, + currentTeamId, + }; +}); export default withDatabase(enhanced(Categories)); 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 deleted file mode 100644 index 52221246e..000000000 --- a/app/screens/home/channel_list/categories_list/categories/unreads/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {of as of$} from 'rxjs'; -import {combineLatestWith, map, switchMap} from 'rxjs/operators'; - -import {Preferences} from '@constants'; -import {getSidebarPreferenceAsBool} from '@helpers/api/preference'; -import {filterAndSortMyChannels, makeChannelsMap} from '@helpers/database'; -import {getChannelById, observeChannelsByLastPostAt, observeNotifyPropsByChannels, queryMyChannelUnreads} from '@queries/servers/channel'; -import {querySidebarPreferences} from '@queries/servers/preference'; -import {observeLastUnreadChannelId} from '@queries/servers/system'; -import {observeUnreadsAndMentions} from '@queries/servers/thread'; - -import UnreadCategories from './unreads'; - -import type {WithDatabaseArgs} from '@typings/database/database'; -import type ChannelModel from '@typings/database/models/servers/channel'; -import type PreferenceModel from '@typings/database/models/servers/preference'; - -type WithDatabaseProps = WithDatabaseArgs & { - currentTeamId: string; - isTablet: boolean; - onlyUnreads: boolean; -} - -type CA = [ - a: Array, - b: ChannelModel | undefined, -] - -const concatenateChannelsArray = ([a, b]: CA) => { - return of$(b ? a.filter((c) => c && c.id !== b.id).concat(b) : a); -}; - -const enhanced = withObservables(['currentTeamId', 'isTablet', 'onlyUnreads'], ({currentTeamId, isTablet, database, onlyUnreads}: WithDatabaseProps) => { - const unreadsOnTop = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS). - observeWithColumns(['value']). - pipe( - switchMap((prefs: PreferenceModel[]) => of$(getSidebarPreferenceAsBool(prefs, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS))), - ); - - const getC = (lastUnreadChannelId: string) => getChannelById(database, lastUnreadChannelId); - - const unreadChannels = unreadsOnTop.pipe(switchMap((gU) => { - if (gU || onlyUnreads) { - const lastUnread = isTablet ? observeLastUnreadChannelId(database).pipe( - switchMap(getC), - ) : of$(undefined); - const myUnreadChannels = queryMyChannelUnreads(database, currentTeamId).observeWithColumns(['last_post_at', 'is_unread']); - const notifyProps = myUnreadChannels.pipe(switchMap((cs) => observeNotifyPropsByChannels(database, cs))); - const channels = myUnreadChannels.pipe(switchMap((myChannels) => observeChannelsByLastPostAt(database, myChannels))); - const channelsMap = channels.pipe(switchMap((cs) => of$(makeChannelsMap(cs)))); - - return myUnreadChannels.pipe( - combineLatestWith(channelsMap, notifyProps), - map(filterAndSortMyChannels), - combineLatestWith(lastUnread), - switchMap(concatenateChannelsArray), - ); - } - return of$([]); - })); - const unreadThreads = observeUnreadsAndMentions(database, {teamId: currentTeamId, includeDmGm: true}); - - return { - unreadChannels, - unreadThreads, - }; -}); - -export default withDatabase(enhanced(UnreadCategories)); diff --git a/app/screens/home/channel_list/categories_list/categories/unreads/unreads.test.tsx b/app/screens/home/channel_list/categories_list/categories/unreads/unreads.test.tsx deleted file mode 100644 index 92b12bba2..000000000 --- a/app/screens/home/channel_list/categories_list/categories/unreads/unreads.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import {renderWithEverything} from '@test/intl-test-helper'; -import TestHelper from '@test/test_helper'; - -import UnreadsCategory from './unreads'; - -import type {Database} from '@nozbe/watermelondb'; - -describe('components/channel_list/categories/body', () => { - let database: Database; - - beforeAll(async () => { - const server = await TestHelper.setupServerDatabase(); - database = server.database; - }); - - it('do not render when there are no unread channels', () => { - const wrapper = renderWithEverything( - undefined} - onlyUnreads={false} - unreadThreads={{unreads: false, mentions: 0}} - />, - {database}, - ); - - expect(wrapper.toJSON()).toBeNull(); - }); -}); diff --git a/app/screens/home/channel_list/categories_list/categories/unreads/unreads.tsx b/app/screens/home/channel_list/categories_list/categories/unreads/unreads.tsx deleted file mode 100644 index 6c6cc3b47..000000000 --- a/app/screens/home/channel_list/categories_list/categories/unreads/unreads.tsx +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useCallback, useMemo} from 'react'; -import {useIntl} from 'react-intl'; -import {FlatList, Text} from 'react-native'; - -import ChannelItem from '@components/channel_item'; -import {HOME_PADDING} from '@constants/view'; -import {useTheme} from '@context/theme'; -import {useIsTablet} from '@hooks/device'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; - -import Empty from './empty_state'; - -import type ChannelModel from '@typings/database/models/servers/channel'; - -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ - empty: { - alignItems: 'center', - flexGrow: 1, - justifyContent: 'center', - }, - heading: { - color: changeOpacity(theme.sidebarText, 0.64), - ...typography('Heading', 75), - textTransform: 'uppercase', - paddingVertical: 8, - marginTop: 12, - ...HOME_PADDING, - }, -})); - -type UnreadCategoriesProps = { - onChannelSwitch: (channel: Channel | ChannelModel) => void; - onlyUnreads: boolean; - unreadChannels: ChannelModel[]; - unreadThreads: {unreads: boolean; mentions: number}; -} - -const extractKey = (item: ChannelModel) => item.id; - -const UnreadCategories = ({onChannelSwitch, onlyUnreads, unreadChannels, unreadThreads}: UnreadCategoriesProps) => { - const intl = useIntl(); - const theme = useTheme(); - const isTablet = useIsTablet(); - const styles = getStyleSheet(theme); - - const renderItem = useCallback(({item}: {item: ChannelModel}) => { - return ( - - ); - }, [onChannelSwitch]); - - const showEmptyState = onlyUnreads && !unreadChannels.length; - const containerStyle = useMemo(() => { - return [ - showEmptyState && !isTablet && styles.empty, - ]; - }, [styles, showEmptyState, isTablet]); - - const showTitle = !onlyUnreads || (onlyUnreads && !showEmptyState); - const EmptyState = showEmptyState && !isTablet ? ( - - ) : undefined; - - if (!unreadChannels.length && !unreadThreads.mentions && !unreadThreads.unreads && !onlyUnreads) { - return null; - } - - return ( - <> - {showTitle && - - {intl.formatMessage({id: 'mobile.channel_list.unreads', defaultMessage: 'Unreads'})} - - } - - - ); -}; - -export default UnreadCategories; diff --git a/app/screens/home/channel_list/categories_list/categories_list.tsx b/app/screens/home/channel_list/categories_list/categories_list.tsx index 94f9db22d..26838ca35 100644 --- a/app/screens/home/channel_list/categories_list/categories_list.tsx +++ b/app/screens/home/channel_list/categories_list/categories_list.tsx @@ -148,10 +148,10 @@ const CategoriesList = ({ {threadButtonComponent} {draftsButtonComponent} {playbooksButtonComponent} - + ); - }, [draftsButtonComponent, hasChannels, playbooksButtonComponent, threadButtonComponent]); + }, [draftsButtonComponent, hasChannels, isTablet, playbooksButtonComponent, threadButtonComponent]); return ( diff --git a/test/test_helper.ts b/test/test_helper.ts index a6bb7a855..1ac4b8df0 100644 --- a/test/test_helper.ts +++ b/test/test_helper.ts @@ -26,6 +26,7 @@ import type PlaybookChecklistItemModel from '@playbooks/types/database/models/pl import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run'; import type PlaybookRunAttributeModel from '@playbooks/types/database/models/playbook_run_attribute'; import type PlaybookRunAttributeValueModel from '@playbooks/types/database/models/playbook_run_attribute_value'; +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'; import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; @@ -643,6 +644,27 @@ class TestHelperSingleton { }; }; + fakeCategoryModel = (overwrite?: Partial): CategoryModel => { + return { + ...this.fakeModel(), + displayName: this.generateId(), + type: 'custom', + sortOrder: 0, + sorting: 'alpha', + muted: false, + collapsed: false, + teamId: this.generateId(), + team: this.fakeRelation(), + categoryChannels: this.fakeQuery([]), + categoryChannelsBySortOrder: this.fakeQuery([]), + channels: this.fakeQuery([]), + myChannels: this.fakeQuery([]), + observeHasChannels: jest.fn(), + toCategoryWithChannels: jest.fn(), + ...overwrite, + }; + }; + fakeDraftModel = (overwrite?: Partial): DraftModel => { return { ...this.fakeModel(),