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:
parent
522812e126
commit
01673ac092
17 changed files with 939 additions and 633 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)));
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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)),
|
||||
);
|
||||
};
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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]}>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue