diff --git a/app/components/channel_list/categories/body/category_body.test.tsx b/app/components/channel_list/categories/body/category_body.test.tsx index cd356efbb8..9dad4a1b20 100644 --- a/app/components/channel_list/categories/body/category_body.test.tsx +++ b/app/components/channel_list/categories/body/category_body.test.tsx @@ -37,6 +37,7 @@ describe('components/channel_list/categories/body', () => { locale={DEFAULT_LOCALE} currentChannelId={''} currentUserId={''} + unreadChannelIds={new Set()} />, {database}, ); diff --git a/app/components/channel_list/categories/body/index.ts b/app/components/channel_list/categories/body/index.ts index 0d8b399c3c..d09225f5e1 100644 --- a/app/components/channel_list/categories/body/index.ts +++ b/app/components/channel_list/categories/body/index.ts @@ -66,14 +66,16 @@ const observeSettings = (database: Database, channels: ChannelModel[]) => { return queryMyChannelSettingsByIds(database, ids).observeWithColumns(['notify_props']); }; -const getChannelsFromRelation = async (relations: CategoryChannelModel[] | MyChannelModel[]) => { +export const getChannelsFromRelation = async (relations: CategoryChannelModel[] | MyChannelModel[]) => { return Promise.all(relations.map((r) => r.channel?.fetch())); }; -const getSortedChannels = (database: Database, category: CategoryModel, locale: string) => { +const getSortedChannels = (database: Database, category: CategoryModel, unreadChannelIds: Set, locale: string) => { switch (category.sorting) { case 'alpha': { - const channels = category.channels.observeWithColumns(['display_name']); + const channels = category.channels.observeWithColumns(['display_name']).pipe( + map((cs) => cs.filter((c) => !unreadChannelIds.has(c.id))), + ); const settings = channels.pipe( switchMap((cs) => observeSettings(database, cs)), ); @@ -83,12 +85,14 @@ const getSortedChannels = (database: Database, category: CategoryModel, locale: } case 'manual': { return category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']).pipe( + map((cc) => cc.filter((c) => !unreadChannelIds.has(c.channelId))), map(getChannelsFromRelation), concatAll(), ); } default: return category.myChannels.observeWithColumns(['last_post_at']).pipe( + map((myCs) => myCs.filter((myC) => !unreadChannelIds.has(myC.id))), map(getChannelsFromRelation), concatAll(), ); @@ -99,12 +103,17 @@ const mapPrefName = (prefs: PreferenceModel[]) => of$(prefs.map((p) => p.name)); const mapChannelIds = (channels: ChannelModel[]) => of$(channels.map((c) => c.id)); -type EnhanceProps = {category: CategoryModel; locale: string; currentUserId: string} & WithDatabaseArgs +type EnhanceProps = { + category: CategoryModel; + locale: string; + currentUserId: string; + unreadChannelIds: Set; +} & WithDatabaseArgs -const enhance = withObservables(['category'], ({category, locale, database, currentUserId}: EnhanceProps) => { +const enhance = withObservables(['category', 'locale', 'unreadChannelIds'], ({category, locale, database, currentUserId, unreadChannelIds}: EnhanceProps) => { const observedCategory = category.observe(); const sortedChannels = observedCategory.pipe( - switchMap((c) => getSortedChannels(database, c, locale)), + switchMap((c) => getSortedChannels(database, c, unreadChannelIds, locale)), ); const dmMap = (p: PreferenceModel) => getDirectChannelName(p.name, currentUserId); diff --git a/app/components/channel_list/categories/categories.tsx b/app/components/channel_list/categories/categories.tsx index c63d8189de..f4ccb5e15f 100644 --- a/app/components/channel_list/categories/categories.tsx +++ b/app/components/channel_list/categories/categories.tsx @@ -1,35 +1,40 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {useIntl} from 'react-intl'; import {FlatList, StyleSheet} from 'react-native'; import CategoryBody from './body'; import LoadCategoriesError from './error'; import CategoryHeader from './header'; +import UnreadCategories from './unreads'; import type CategoryModel from '@typings/database/models/servers/category'; +import type ChannelModel from '@typings/database/models/servers/channel'; type Props = { categories: CategoryModel[]; + unreadChannels: ChannelModel[]; currentChannelId: string; currentUserId: string; currentTeamId: string; } const styles = StyleSheet.create({ - flex: { + mainList: { flex: 1, }, }); const extractKey = (item: CategoryModel) => item.id; -const Categories = ({categories, currentChannelId, currentUserId, currentTeamId}: Props) => { +const Categories = ({categories, currentChannelId, currentUserId, currentTeamId, unreadChannels}: Props) => { const intl = useIntl(); const listRef = useRef(null); + const unreadChannelIds = useMemo(() => new Set(unreadChannels.map((myC) => myC.id)), [unreadChannels]); + const renderCategory = useCallback((data: {item: CategoryModel}) => { return ( <> @@ -39,6 +44,7 @@ const Categories = ({categories, currentChannelId, currentUserId, currentTeamId} currentChannelId={currentChannelId} currentUserId={currentUserId} locale={intl.locale} + unreadChannelIds={unreadChannelIds} /> ); @@ -56,20 +62,23 @@ const Categories = ({categories, currentChannelId, currentUserId, currentTeamId} } return ( - + <> + {unreadChannels.length > 0 && } + + ); }; diff --git a/app/components/channel_list/categories/index.ts b/app/components/channel_list/categories/index.ts index 7ee77ad1ce..3f3848e00b 100644 --- a/app/components/channel_list/categories/index.ts +++ b/app/components/channel_list/categories/index.ts @@ -3,15 +3,23 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {concatAll, map, switchMap} from 'rxjs/operators'; +import {Preferences} from '@app/constants'; +import {getPreferenceAsBool} from '@app/helpers/api/preference'; +import {queryMyChannelUnreads} from '@app/queries/servers/channel'; +import {queryPreferencesByCategoryAndName} from '@app/queries/servers/preference'; import {queryCategoriesByTeamIds} from '@queries/servers/categories'; import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system'; +import {getChannelsFromRelation} from './body'; import Categories from './categories'; import type {WithDatabaseArgs} from '@typings/database/database'; +import type PreferenceModel from '@typings/database/models/servers/preference'; -type WithDatabaseProps = {currentTeamId: string } & WithDatabaseArgs +type WithDatabaseProps = { currentTeamId: string } & WithDatabaseArgs const enhanced = withObservables( ['currentTeamId'], @@ -20,10 +28,27 @@ const enhanced = withObservables( const currentUserId = observeCurrentUserId(database); const categories = queryCategoriesByTeamIds(database, [currentTeamId]).observeWithColumns(['sort_order']); + const unreadsOnTop = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS). + observe(). + pipe( + switchMap((prefs: PreferenceModel[]) => of$(getPreferenceAsBool(prefs, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS, false))), + ); + + const unreadChannels = unreadsOnTop.pipe(switchMap((gU) => { + if (gU) { + return queryMyChannelUnreads(database, currentTeamId).observe().pipe( + map(getChannelsFromRelation), + concatAll(), + ); + } + return of$([]); + })); + return { - currentChannelId, + unreadChannels, categories, currentUserId, + currentChannelId, }; }); diff --git a/app/components/channel_list/categories/unreads.test.tsx b/app/components/channel_list/categories/unreads.test.tsx new file mode 100644 index 0000000000..a415836d1b --- /dev/null +++ b/app/components/channel_list/categories/unreads.test.tsx @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} from '@nozbe/watermelondb'; +import React from 'react'; + +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import UnreadsCategory from './unreads'; + +describe('components/channel_list/categories/body', () => { + let database: Database; + + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + it('render without error', () => { + const wrapper = renderWithEverything( + , + {database}, + ); + + expect(wrapper.toJSON()).toBeTruthy(); + }); +}); diff --git a/app/components/channel_list/categories/unreads.tsx b/app/components/channel_list/categories/unreads.tsx new file mode 100644 index 0000000000..6d132bd524 --- /dev/null +++ b/app/components/channel_list/categories/unreads.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {FlatList, Text} from 'react-native'; + +import {changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme'; +import {useTheme} from '@context/theme'; +import {typography} from '@utils/typography'; + +import ChannelListItem from './body/channel'; + +import type ChannelModel from '@typings/database/models/servers/channel'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + heading: { + color: changeOpacity(theme.sidebarText, 0.64), + ...typography('Heading', 75), + paddingLeft: 5, + paddingTop: 10, + }, +})); + +const renderItem = ({item}: {item: ChannelModel}) => { + return ( + + ); +}; + +const UnreadCategories = ({unreadChannels}: {unreadChannels: ChannelModel[]}) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + const intl = useIntl(); + + return ( + <> + + {intl.formatMessage({id: 'mobile.channel_list.unreads', defaultMessage: 'UNREADS'})} + + + + ); +}; + +export default UnreadCategories; diff --git a/app/constants/preferences.ts b/app/constants/preferences.ts index b87199f4fa..46108a821e 100644 --- a/app/constants/preferences.ts +++ b/app/constants/preferences.ts @@ -40,6 +40,7 @@ const Preferences: Record = { CHANNEL_SIDEBAR_ORGANIZATION: 'channel_sidebar_organization', CHANNEL_SIDEBAR_LIMIT_DMS: 'limit_visible_dms_gms', CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT: 20, + CHANNEL_SIDEBAR_GROUP_UNREADS: 'show_unread_section', AUTOCLOSE_DMS_ENABLED: 'after_seven_days', CATEGORY_ADVANCED_SETTINGS: 'advanced_settings', ADVANCED_FILTER_JOIN_LEAVE: 'join_leave', diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 56e9d12ec9..1e7a5845ab 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -373,6 +373,20 @@ export const queryChannelsByNames = (database: Database, names: string[]) => { return database.get(CHANNEL).query(Q.where('name', Q.oneOf(names))); }; +export const queryMyChannelUnreads = (database: Database, currentTeamId: string) => { + return database.get(MY_CHANNEL).query( + Q.on( + CHANNEL, + Q.or( + Q.where('team_id', Q.eq(currentTeamId)), + Q.where('team_id', Q.eq('')), + ), + ), + Q.where('is_unread', Q.eq(true)), + Q.sortBy('last_post_at', Q.desc), + ); +}; + export function observeMyChannelMentionCount(database: Database, teamId?: string, columns = ['mentions_count', 'is_unread']): Observable { const conditions: Q.Condition[] = [ Q.where('delete_at', Q.eq(0)), diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index f2e2622a5e..80dafecaa4 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -240,6 +240,7 @@ "mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera", "mobile.channel_info.alertNo": "No", "mobile.channel_info.alertYes": "Yes", + "mobile.channel_list.unreads": "UNREADS", "mobile.commands.error_title": "Error Executing Command", "mobile.components.select_server_view.connect": "Connect", "mobile.components.select_server_view.connecting": "Connecting",