Refactor categories list (#9270)

* Refactor categories list from nested list to a flattened list

* Refactor categories list to be flatten instead of nested
This commit is contained in:
Elias Nahum 2025-12-23 07:29:06 +02:00 committed by GitHub
parent 522812e126
commit 01673ac092
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 939 additions and 633 deletions

View file

@ -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,
};

View file

@ -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<CategoryModel>(CATEGORY).query(
Q.take(1),
).fetch();
category = categories[0];
});
it('should match snapshot', (done) => {
const wrapper = renderWithEverything(
<CategoryBody
category={category}
locale={DEFAULT_LOCALE}
isTablet={false}
onChannelSwitch={() => undefined}
/>,
{database},
);
setTimeout(() => {
expect(wrapper.toJSON()).toBeTruthy();
done();
});
});
});

View file

@ -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<string>;
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 (
<ChannelItem
channel={item}
onPress={onChannelSwitch}
key={item.id}
testID={`channel_list.category.${category.displayName.replace(/ /g, '_').toLocaleLowerCase()}.channel_item`}
shouldHighlightActive={isChannelScreenActive}
shouldHighlightState={true}
isOnHome={true}
/>
);
}, [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 (
<Animated.View style={animatedStyle}>
<FlatList
data={category.collapsed ? unreadChannels : ids}
renderItem={renderItem}
keyExtractor={extractKey}
// @ts-expect-error strictMode not exposed on the types
strictMode={true}
style={listStyle}
/>
</Animated.View>
);
};
export default CategoryBody;

View file

@ -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<MyChannelModel[]>) => {
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<string, ChannelModel>(cs.map((c) => [c.id, c]));
const categoryChannelMap = new Map<string, number>(sorted.map((s) => [s.channelId, s.sortOrder]));
return of$(my.reduce<ChannelWithMyChannel[]>((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)));

View file

@ -28,7 +28,7 @@ describe('components/channel_list/categories', () => {
it('render without error', () => {
const wrapper = renderWithEverything(
<Categories/>,
<Categories isTablet={false}/>,
{database},
);
@ -49,12 +49,12 @@ describe('performance metrics', () => {
});
it('properly send metric on load', () => {
renderWithEverything(<Categories/>, {database, serverUrl});
renderWithEverything(<Categories isTablet={false}/>, {database, serverUrl});
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_team_switch', serverUrl);
});
it('properly call again after switching teams', async () => {
renderWithEverything(<Categories/>, {database, serverUrl});
renderWithEverything(<Categories isTablet={false}/>, {database, serverUrl});
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(1);
act(() => {
DeviceEventEmitter.emit(Events.TEAM_SWITCH, true);

View file

@ -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<string>;
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<FlatList>(null);
const Categories = ({flattenedItems, unreadChannelIds, onlyUnreads, isTablet}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const listRef = useRef<FlashList<FlattenedItem>>(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<FlattenedItem> = useCallback(({item}) => {
if (item.type === 'unreads_header') {
return (
<UnreadCategories
currentTeamId={teamId}
isTablet={isTablet}
onChannelSwitch={onChannelSwitch}
onlyUnreads={showOnlyUnreadsCategory}
<FormattedText
id='mobile.channel_list.unreads'
defaultMessage='Unreads'
style={styles.unreadsHeading}
/>
);
}
if (item.type === 'header') {
return <CategoryHeader category={item.category}/>;
}
// item.type === 'channel'
const testIdSuffix = item.categoryId === UNREADS_CATEGORY ? UNREADS_CATEGORY : item.channelId;
return (
<>
<CategoryHeader category={data.item}/>
<CategoryBody
category={data.item}
isTablet={isTablet}
locale={intl.locale}
onChannelSwitch={onChannelSwitch}
/>
</>
<ChannelItem
channel={item.channel}
onPress={onChannelSwitch}
testID={`channel_list.category.${testIdSuffix}.channel_item`}
shouldHighlightActive={isChannelScreenActive}
shouldHighlightState={true}
isOnHome={true}
/>
);
}, [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 (
<View style={{height: listHeight}}>
<Empty/>
</View>
);
}
return undefined;
}, [showEmptyState, listHeight]);
if (flattenedItems.length === 0 && !switchingTeam && !initialLoad && !onlyUnreads) {
return <LoadCategoriesError/>;
}
return (
<>
{!switchingTeam && !initiaLoad && showOnlyUnreadsCategory &&
<View style={styles.mainList}>
<UnreadCategories
currentTeamId={teamId}
isTablet={isTablet}
onChannelSwitch={onChannelSwitch}
onlyUnreads={showOnlyUnreadsCategory}
/>
</View>
}
{!switchingTeam && !initiaLoad && !showOnlyUnreadsCategory && (
<FlatList
data={categoriesToShow}
{!switchingTeam && !initialLoad && (
<FlashList<FlattenedItem>
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) && (
<View style={styles.loadingView}>
<Loading
size='large'

View file

@ -12,11 +12,7 @@ import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import EmptyIllustration from './empty_unreads';
type Props = {
onlyUnreads: boolean;
}
import EmptyIllustration from './empty_unreads_svg';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
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 (

View file

@ -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<FlattenedItem, {type: 'header'}>;
expect(headerItem.categoryId).toBe('cat1');
const channelItem1 = result[1] as Extract<FlattenedItem, {type: 'channel'}>;
expect(channelItem1.channelId).toBe('channel1');
expect(channelItem1.categoryId).toBe('cat1');
const channelItem2 = result[2] as Extract<FlattenedItem, {type: 'channel'}>;
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<FlattenedItem, {type: 'channel'}>;
expect(channelItem1.channelId).toBe('channel1');
const channelItem2 = result[2] as Extract<FlattenedItem, {type: 'channel'}>;
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<FlattenedItem, {type: 'channel'}>;
expect(unreadChannel1.channelId).toBe('channel1');
expect(unreadChannel1.categoryId).toBe(UNREADS_CATEGORY);
const unreadChannel2 = result[2] as Extract<FlattenedItem, {type: 'channel'}>;
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);
});
});
});
});

View file

@ -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<string>;
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<string>();
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;
};

View file

@ -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<string>) => {
return channels.filter((c) => !unreadIds.has(c.id));
};
const observeCategoryChannels = (category: CategoryModel, myChannels: Observable<MyChannelModel[]>) => {
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<string, ChannelModel>(cs.map((c) => [c.id, c]));
const categoryChannelMap = new Map<string, number>(sorted.map((s) => [s.channelId, s.sortOrder]));
return my.reduce<ChannelWithMyChannel[]>((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<CategoryData> => {
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<string>;
};
// Observable for onlyUnreads mode - flat list of unread channels sorted by lastPostAt
const observeFlattenedUnreads = (
database: Database,
currentTeamId: string,
isTablet: boolean,
): Observable<FlattenedCategoriesData> => {
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<string>();
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<FlattenedCategoriesData> => {
if (categories.length === 0) {
return of$({items: [], unreadChannelIds: new Set<string>()});
}
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<string>();
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<FlattenedCategoriesData> => {
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)),
);
};

View file

@ -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<string>(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));

View file

@ -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<ChannelModel | null>,
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));

View file

@ -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(
<UnreadsCategory
unreadChannels={[]}
onChannelSwitch={() => undefined}
onlyUnreads={false}
unreadThreads={{unreads: false, mentions: 0}}
/>,
{database},
);
expect(wrapper.toJSON()).toBeNull();
});
});

View file

@ -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 (
<ChannelItem
channel={item}
onPress={onChannelSwitch}
testID='channel_list.category.unreads.channel_item'
shouldHighlightActive={true}
shouldHighlightState={true}
isOnHome={true}
/>
);
}, [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 ? (
<Empty onlyUnreads={onlyUnreads}/>
) : undefined;
if (!unreadChannels.length && !unreadThreads.mentions && !unreadThreads.unreads && !onlyUnreads) {
return null;
}
return (
<>
{showTitle &&
<Text
style={styles.heading}
>
{intl.formatMessage({id: 'mobile.channel_list.unreads', defaultMessage: 'Unreads'})}
</Text>
}
<FlatList
contentContainerStyle={containerStyle}
data={unreadChannels}
renderItem={renderItem}
keyExtractor={extractKey}
ListEmptyComponent={EmptyState}
removeClippedSubviews={true}
/>
</>
);
};
export default UnreadCategories;

View file

@ -148,10 +148,10 @@ const CategoriesList = ({
{threadButtonComponent}
{draftsButtonComponent}
{playbooksButtonComponent}
<Categories/>
<Categories isTablet={isTablet}/>
</>
);
}, [draftsButtonComponent, hasChannels, playbooksButtonComponent, threadButtonComponent]);
}, [draftsButtonComponent, hasChannels, isTablet, playbooksButtonComponent, threadButtonComponent]);
return (
<Animated.View style={[styles.container, tabletStyle]}>

View file

@ -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>): 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>): DraftModel => {
return {
...this.fakeModel(),