From d14ce66897e3585049cf3fa27ec7f5c6036c0444 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Fri, 28 Jan 2022 09:51:30 -0300 Subject: [PATCH] [Gekidou] Multi-Server support UI (#5912) * Multi-Server support UI * feedback review * Apply suggestions from code review Co-authored-by: Avinash Lingaloo Co-authored-by: Avinash Lingaloo --- .../__snapshots__/server_icon.test.tsx.snap | 166 +++++++++++ app/components/server_icon/index.tsx | 80 ++++++ .../server_icon/server_icon.test.tsx | 43 +++ .../add_team/{add_team.tsx => index.tsx} | 42 +-- .../team_sidebar/add_team/team_list.tsx | 3 +- app/components/team_sidebar/team_sidebar.tsx | 2 +- app/constants/events.ts | 1 + app/database/components/index.tsx | 13 +- app/database/subscription/servers.ts | 30 ++ app/database/subscription/unreads.ts | 54 ++++ app/screens/bottom_sheet/index.tsx | 2 +- .../channel/other_mentions_badge/index.tsx | 63 ++--- .../home/channel_list/channel_list.tsx | 5 +- .../__snapshots__/server_icon.test.tsx.snap | 26 -- .../server_icon/server_icon.test.tsx | 16 -- .../channel_list/server_icon/server_icon.tsx | 39 --- .../home/channel_list/servers/index.tsx | 143 ++++++++++ .../servers/servers_list/index.tsx | 72 +++++ .../servers_list/server_item/index.tsx | 257 ++++++++++++++++++ .../server_item/options/index.tsx | 81 ++++++ .../server_item/options/option.tsx | 72 +++++ app/screens/home/tab_bar/home.tsx | 72 ++--- app/screens/navigation.ts | 7 +- assets/base/i18n/en.json | 8 +- types/database/subscriptions/index.ts | 13 + 25 files changed, 1092 insertions(+), 218 deletions(-) create mode 100644 app/components/server_icon/__snapshots__/server_icon.test.tsx.snap create mode 100644 app/components/server_icon/index.tsx create mode 100644 app/components/server_icon/server_icon.test.tsx rename app/components/team_sidebar/add_team/{add_team.tsx => index.tsx} (65%) create mode 100644 app/database/subscription/servers.ts create mode 100644 app/database/subscription/unreads.ts delete mode 100644 app/screens/home/channel_list/server_icon/__snapshots__/server_icon.test.tsx.snap delete mode 100644 app/screens/home/channel_list/server_icon/server_icon.test.tsx delete mode 100644 app/screens/home/channel_list/server_icon/server_icon.tsx create mode 100644 app/screens/home/channel_list/servers/index.tsx create mode 100644 app/screens/home/channel_list/servers/servers_list/index.tsx create mode 100644 app/screens/home/channel_list/servers/servers_list/server_item/index.tsx create mode 100644 app/screens/home/channel_list/servers/servers_list/server_item/options/index.tsx create mode 100644 app/screens/home/channel_list/servers/servers_list/server_item/options/option.tsx create mode 100644 types/database/subscriptions/index.ts diff --git a/app/components/server_icon/__snapshots__/server_icon.test.tsx.snap b/app/components/server_icon/__snapshots__/server_icon.test.tsx.snap new file mode 100644 index 000000000..8bd15dfda --- /dev/null +++ b/app/components/server_icon/__snapshots__/server_icon.test.tsx.snap @@ -0,0 +1,166 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Server Icon Server Icon Component should match snapshot 1`] = ` + + + + + +`; + +exports[`Server Icon Server Icon Component should match snapshot with mentions 1`] = ` + + + + + 1 + + + +`; + +exports[`Server Icon Server Icon Component should match snapshot with unreads 1`] = ` + + + + + + + + +`; diff --git a/app/components/server_icon/index.tsx b/app/components/server_icon/index.tsx new file mode 100644 index 000000000..208181909 --- /dev/null +++ b/app/components/server_icon/index.tsx @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; + +import Badge from '@components/badge'; +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {useTheme} from '@context/theme'; +import {changeOpacity} from '@utils/theme'; + +type Props = { + badgeBackgroundColor?: string; + badgeBorderColor?: string; + badgeColor?: string; + badgeStyle?: StyleProp; + hasUnreads: boolean; + iconColor?: string; + mentionCount: number; + onPress?: () => void; + size?: number; + style?: StyleProp; + unreadStyle?: StyleProp; +} + +const styles = StyleSheet.create({ + badge: { + left: 25, + }, + unread: { + top: 5, + }, +}); + +export default function ServerIcon({ + badgeBackgroundColor, + badgeBorderColor, + badgeColor, + badgeStyle, + hasUnreads, + iconColor, + mentionCount, + onPress, + size = 24, + style, + unreadStyle, +}: Props) { + const theme = useTheme(); + const hasBadge = Boolean(mentionCount || hasUnreads); + const count = mentionCount || (hasUnreads ? -1 : 0); + const memoizedStyle = useMemo(() => { + return [(badgeStyle || styles.badge), count > -1 ? undefined : (unreadStyle || styles.unread)]; + }, [badgeStyle, count, unreadStyle]); + + return ( + + + + + + + ); +} + diff --git a/app/components/server_icon/server_icon.test.tsx b/app/components/server_icon/server_icon.test.tsx new file mode 100644 index 000000000..dc9c498e9 --- /dev/null +++ b/app/components/server_icon/server_icon.test.tsx @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithIntlAndTheme} from '@test/intl-test-helper'; + +import Icon from './index'; + +describe('Server Icon', () => { + test('Server Icon Component should match snapshot', () => { + const {toJSON} = renderWithIntlAndTheme( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + test('Server Icon Component should match snapshot with unreads', () => { + const {toJSON} = renderWithIntlAndTheme( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + test('Server Icon Component should match snapshot with mentions', () => { + const {toJSON} = renderWithIntlAndTheme( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/team_sidebar/add_team/add_team.tsx b/app/components/team_sidebar/add_team/index.tsx similarity index 65% rename from app/components/team_sidebar/add_team/add_team.tsx rename to app/components/team_sidebar/add_team/index.tsx index 3888ea3b7..f8c64778b 100644 --- a/app/components/team_sidebar/add_team/add_team.tsx +++ b/app/components/team_sidebar/add_team/index.tsx @@ -4,14 +4,12 @@ import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; import {useWindowDimensions, View} from 'react-native'; -import {OptionsModalPresentationStyle} from 'react-native-navigation'; import CompassIcon from '@components/compass_icon'; import TouchableWithFeedback from '@components/touchable_with_feedback'; -import {Screens} from '@constants'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; -import {showModal, showModalOverCurrentContext} from '@screens/navigation'; +import {bottomSheet} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -52,37 +50,13 @@ export default function AddTeam({canCreateTeams, otherTeams}: Props) { height = Math.min(maxHeight, HEADER_HEIGHT + (otherTeams.length * ITEM_HEIGHT) + (canCreateTeams ? CREATE_HEIGHT : 0)); } - if (isTablet) { - const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor); - const closeButtonId = 'close-join-team'; - showModal(Screens.BOTTOM_SHEET, intl.formatMessage({id: 'mobile.add_team.join_team', defaultMessage: 'Join Another Team'}), { - closeButtonId, - renderContent, - snapPoints: [height, 10], - }, { - modalPresentationStyle: OptionsModalPresentationStyle.formSheet, - swipeToDismiss: true, - topBar: { - leftButtons: [{ - id: closeButtonId, - icon: closeButton, - testID: closeButtonId, - }], - leftButtonColor: changeOpacity(theme.centerChannelColor, 0.56), - background: { - color: theme.centerChannelBg, - }, - title: { - color: theme.centerChannelColor, - }, - }, - }); - } else { - showModalOverCurrentContext(Screens.BOTTOM_SHEET, { - renderContent, - snapPoints: [height, 10], - }, {swipeToDismiss: true}); - } + bottomSheet({ + closeButtonId: 'close-join-team', + renderContent, + snapPoints: [height, 10], + theme, + title: intl.formatMessage({id: 'mobile.add_team.join_team', defaultMessage: 'Join Another Team'}), + }); }), [canCreateTeams, otherTeams, isTablet, theme]); return ( diff --git a/app/components/team_sidebar/add_team/team_list.tsx b/app/components/team_sidebar/add_team/team_list.tsx index 48735faba..723ea12f8 100644 --- a/app/components/team_sidebar/add_team/team_list.tsx +++ b/app/components/team_sidebar/add_team/team_list.tsx @@ -2,7 +2,8 @@ // See LICENSE.txt for license information. import React from 'react'; -import {FlatList, ListRenderItemInfo, View} from 'react-native'; +import {ListRenderItemInfo, View} from 'react-native'; +import {FlatList} from 'react-native-gesture-handler'; // Keep the FlatList from gesture handler so it works well with bottom sheet import FormattedText from '@components/formatted_text'; import {useTheme} from '@context/theme'; diff --git a/app/components/team_sidebar/team_sidebar.tsx b/app/components/team_sidebar/team_sidebar.tsx index afec0bd52..00d89d51a 100644 --- a/app/components/team_sidebar/team_sidebar.tsx +++ b/app/components/team_sidebar/team_sidebar.tsx @@ -10,7 +10,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {makeStyleSheetFromTheme} from '@utils/theme'; -import AddTeam from './add_team/add_team'; +import AddTeam from './add_team'; import TeamList from './team_list'; import type TeamModel from '@typings/database/models/servers/team'; diff --git a/app/constants/events.ts b/app/constants/events.ts index 1aeaf8646..e1247d3f7 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -16,4 +16,5 @@ export default keyMirror({ TEAM_LOAD_ERROR: null, USER_TYPING: null, USER_STOP_TYPING: null, + SWIPEABLE: null, }); diff --git a/app/database/components/index.tsx b/app/database/components/index.tsx index 1adc723c2..7590e0841 100644 --- a/app/database/components/index.tsx +++ b/app/database/components/index.tsx @@ -1,15 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database, Q} from '@nozbe/watermelondb'; +import {Database} from '@nozbe/watermelondb'; import DatabaseProvider from '@nozbe/watermelondb/DatabaseProvider'; import React, {ComponentType, useEffect, useState} from 'react'; -import {MM_TABLES} from '@constants/database'; import ServerProvider from '@context/server'; import ThemeProvider from '@context/theme'; import UserLocaleProvider from '@context/user_locale'; import DatabaseManager from '@database/manager'; +import {subscribeActiveServers} from '@database/subscription/servers'; import type ServersModel from '@typings/database/models/app/servers'; @@ -19,12 +19,9 @@ type State = { serverDisplayName: string; }; -const {SERVERS} = MM_TABLES.APP; - export function withServerDatabase(Component: ComponentType): ComponentType { return function ServerDatabaseComponent(props) { const [state, setState] = useState(); - const db = DatabaseManager.appDatabase?.database; const observer = (servers: ServersModel[]) => { const server = servers?.length ? servers.reduce((a, b) => @@ -46,11 +43,7 @@ export function withServerDatabase(Component: ComponentType): ComponentTyp }; useEffect(() => { - const subscription = db?.collections. - get(SERVERS). - query(Q.where('identifier', Q.notEq(''))). - observeWithColumns(['last_active_at']). - subscribe(observer); + const subscription = subscribeActiveServers(observer); return () => { subscription?.unsubscribe(); diff --git a/app/database/subscription/servers.ts b/app/database/subscription/servers.ts new file mode 100644 index 000000000..e152f37d8 --- /dev/null +++ b/app/database/subscription/servers.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; +import DatabaseManager from '@database/manager'; + +import type ServersModel from '@typings/database/models/app/servers'; + +const {SERVERS} = MM_TABLES.APP; + +export const subscribeActiveServers = (observer: (servers: ServersModel[]) => void) => { + const db = DatabaseManager.appDatabase?.database; + return db?. + get(SERVERS). + query(Q.where('identifier', Q.notEq(''))). + observeWithColumns(['last_active_at']). + subscribe(observer); +}; + +export const subscribeAllServers = (observer: (servers: ServersModel[]) => void) => { + const db = DatabaseManager.appDatabase?.database; + return db?. + get(SERVERS). + query(Q.sortBy('display_name', Q.asc)). + observeWithColumns(['last_active_at']). + subscribe(observer); +}; + diff --git a/app/database/subscription/unreads.ts b/app/database/subscription/unreads.ts new file mode 100644 index 000000000..e20300452 --- /dev/null +++ b/app/database/subscription/unreads.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; +import DatabaseManager from '@database/manager'; + +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type {Subscription} from 'rxjs'; + +const {SERVER: {CHANNEL, MY_CHANNEL}} = MM_TABLES; + +export const subscribeServerUnreadAndMentions = (serverUrl: string, observer: (myChannels: MyChannelModel[]) => void) => { + const server = DatabaseManager.serverDatabases[serverUrl]; + let subscription: Subscription|undefined; + + if (server?.database) { + subscription = server.database.get(MY_CHANNEL). + query(Q.on(CHANNEL, Q.where('delete_at', Q.eq(0)))). + observeWithColumns(['is_unread', 'mentions_count']). + subscribe(observer); + } + + return subscription; +}; + +export const subscribeMentionsByServer = (serverUrl: string, observer: (serverUrl: string, myChannels: MyChannelModel[]) => void) => { + const server = DatabaseManager.serverDatabases[serverUrl]; + let subscription: Subscription|undefined; + if (server?.database) { + subscription = server.database. + get(MY_CHANNEL). + query(Q.on(CHANNEL, Q.where('delete_at', Q.eq(0)))). + observeWithColumns(['mentions_count']). + subscribe(observer.bind(undefined, serverUrl)); + } + + return subscription; +}; + +export const subscribeUnreadAndMentionsByServer = (serverUrl: string, observer: (serverUrl: string, myChannels: MyChannelModel[]) => void) => { + const server = DatabaseManager.serverDatabases[serverUrl]; + let subscription: Subscription|undefined; + if (server?.database) { + subscription = server.database. + get(MY_CHANNEL). + query(Q.on(CHANNEL, Q.where('delete_at', Q.eq(0)))). + observeWithColumns(['mentions_count', 'has_unreads']). + subscribe(observer.bind(undefined, serverUrl)); + } + + return subscription; +}; diff --git a/app/screens/bottom_sheet/index.tsx b/app/screens/bottom_sheet/index.tsx index 7af97bcc0..ecbfd1829 100644 --- a/app/screens/bottom_sheet/index.tsx +++ b/app/screens/bottom_sheet/index.tsx @@ -124,7 +124,7 @@ const BottomSheet = ({closeButtonId, initialSnapIndex = 0, renderContent, snapPo borderRadius={10} initialSnap={initialSnapIndex} renderContent={renderContainerContent} - onCloseEnd={() => dismissModal()} + onCloseEnd={dismissModal} enabledBottomInitialAnimation={true} renderHeader={Indicator} enabledContentTapInteraction={false} diff --git a/app/screens/channel/other_mentions_badge/index.tsx b/app/screens/channel/other_mentions_badge/index.tsx index 3f950d4ba..cedae5df8 100644 --- a/app/screens/channel/other_mentions_badge/index.tsx +++ b/app/screens/channel/other_mentions_badge/index.tsx @@ -1,22 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Q} from '@nozbe/watermelondb'; import React, {useEffect, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import Badge from '@components/badge'; -import {MM_TABLES} from '@constants/database'; -import DatabaseManager from '@database/manager'; +import {subscribeAllServers} from '@database/subscription/servers'; +import {subscribeMentionsByServer} from '@database/subscription/unreads'; import type ServersModel from '@typings/database/models/app/servers'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; -import type {Subscription} from 'rxjs'; - -type UnreadSubscription = { - mentions: number; - subscription?: Subscription; -} +import type {UnreadSubscription} from '@typings/database/subscriptions'; type Props = { channelId: string; @@ -30,12 +24,9 @@ const styles = StyleSheet.create({ }, }); -const {SERVERS} = MM_TABLES.APP; -const {CHANNEL, MY_CHANNEL} = MM_TABLES.SERVER; const subscriptions: Map = new Map(); const OtherMentionsBadge = ({channelId}: Props) => { - const db = DatabaseManager.appDatabase?.database; const [count, setCount] = useState(0); const updateCount = () => { @@ -50,11 +41,11 @@ const OtherMentionsBadge = ({channelId}: Props) => { const unreads = subscriptions.get(serverUrl); if (unreads) { let mentions = 0; - myChannels.forEach((myChannel) => { + for (const myChannel of myChannels) { if (channelId !== myChannel.id) { mentions += myChannel.mentionsCount; } - }); + } unreads.mentions = mentions; subscriptions.set(serverUrl, unreads); @@ -63,38 +54,32 @@ const OtherMentionsBadge = ({channelId}: Props) => { }; const serversObserver = async (servers: ServersModel[]) => { - servers.forEach((server) => { - const serverUrl = server.url; - if (server.lastActiveAt) { - const sdb = DatabaseManager.serverDatabases[serverUrl]; - if (sdb?.database) { - if (!subscriptions.has(serverUrl)) { - const unreads: UnreadSubscription = { - mentions: 0, - }; - subscriptions.set(serverUrl, unreads); - unreads.subscription = sdb.database. - get(MY_CHANNEL). - query(Q.on(CHANNEL, Q.where('delete_at', Q.eq(0)))). - observeWithColumns(['mentions_count']). - subscribe(unreadsSubscription.bind(undefined, serverUrl)); - } - } + // unsubscribe mentions from servers that were removed + const allUrls = servers.map((s) => s.url); + const subscriptionsToRemove = [...subscriptions].filter(([key]) => allUrls.indexOf(key) === -1); + for (const [key, map] of subscriptionsToRemove) { + map.subscription?.unsubscribe(); + subscriptions.delete(key); + } - // subscribe and listen for mentions + for (const server of servers) { + const serverUrl = server.url; + if (server.lastActiveAt && !subscriptions.has(serverUrl)) { + const unreads: UnreadSubscription = { + mentions: 0, + unread: false, + }; + subscriptions.set(serverUrl, unreads); + unreads.subscription = subscribeMentionsByServer(serverUrl, unreadsSubscription); } else if (subscriptions.has(serverUrl)) { - // logout from server, remove the subscription + subscriptions.get(serverUrl)?.subscription?.unsubscribe(); subscriptions.delete(serverUrl); } - }); + } }; useEffect(() => { - const subscription = db?. - get(SERVERS). - query(). - observeWithColumns(['last_active_at']). - subscribe(serversObserver); + const subscription = subscribeAllServers(serversObserver); return () => { subscription?.unsubscribe(); diff --git a/app/screens/home/channel_list/channel_list.tsx b/app/screens/home/channel_list/channel_list.tsx index 688dcccd8..20736c5dc 100644 --- a/app/screens/home/channel_list/channel_list.tsx +++ b/app/screens/home/channel_list/channel_list.tsx @@ -12,9 +12,10 @@ import TeamSidebar from '@components/team_sidebar'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import Channel from '@screens/channel'; -import ServerIcon from '@screens/home/channel_list/server_icon/server_icon'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import Servers from './servers'; + type ChannelProps = { currentTeamId?: string; teamsCount: number; @@ -81,7 +82,7 @@ const ChannelListScreen = (props: ChannelProps) => { style={styles.content} edges={edges} > - {canAddOtherServers && } + {canAddOtherServers && } diff --git a/app/screens/home/channel_list/server_icon/__snapshots__/server_icon.test.tsx.snap b/app/screens/home/channel_list/server_icon/__snapshots__/server_icon.test.tsx.snap deleted file mode 100644 index c3ca88edb..000000000 --- a/app/screens/home/channel_list/server_icon/__snapshots__/server_icon.test.tsx.snap +++ /dev/null @@ -1,26 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Server Icon Component should match snapshot 1`] = ` - - - -`; diff --git a/app/screens/home/channel_list/server_icon/server_icon.test.tsx b/app/screens/home/channel_list/server_icon/server_icon.test.tsx deleted file mode 100644 index 20ee4f5f5..000000000 --- a/app/screens/home/channel_list/server_icon/server_icon.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import {renderWithIntlAndTheme} from '@test/intl-test-helper'; - -import Icon from './server_icon'; - -test('Server Icon Component should match snapshot', () => { - const {toJSON} = renderWithIntlAndTheme( - , - ); - - expect(toJSON()).toMatchSnapshot(); -}); diff --git a/app/screens/home/channel_list/server_icon/server_icon.tsx b/app/screens/home/channel_list/server_icon/server_icon.tsx deleted file mode 100644 index ac249abd1..000000000 --- a/app/screens/home/channel_list/server_icon/server_icon.tsx +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {StyleSheet, View} from 'react-native'; - -import CompassIcon from '@components/compass_icon'; -import {useTheme} from '@context/theme'; -import {changeOpacity} from '@utils/theme'; - -const styles = StyleSheet.create({ - icon: { - flex: 0, - overflow: 'hidden', - alignItems: 'center', - justifyContent: 'center', - position: 'absolute', - zIndex: 10, - top: 10, - left: 16, - width: 40, - height: 40, - }, -}); - -export default function ServerIcon() { - const theme = useTheme(); - - return ( - - - - ); -} - diff --git a/app/screens/home/channel_list/servers/index.tsx b/app/screens/home/channel_list/servers/index.tsx new file mode 100644 index 000000000..4e799b116 --- /dev/null +++ b/app/screens/home/channel_list/servers/index.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {StyleSheet} from 'react-native'; + +import ServerIcon from '@components/server_icon'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {subscribeAllServers} from '@database/subscription/servers'; +import {subscribeUnreadAndMentionsByServer} from '@database/subscription/unreads'; +import {useIsTablet} from '@hooks/device'; +import {bottomSheet} from '@screens/navigation'; + +import ServerList from './servers_list'; + +import type ServersModel from '@typings/database/models/app/servers'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type {UnreadMessages, UnreadSubscription} from '@typings/database/subscriptions'; + +const subscriptions: Map = new Map(); + +const styles = StyleSheet.create({ + icon: { + alignItems: 'center', + justifyContent: 'center', + position: 'absolute', + zIndex: 10, + top: 10, + left: 16, + width: 40, + height: 40, + }, +}); + +export default function Servers() { + const intl = useIntl(); + const [total, setTotal] = useState({mentions: 0, unread: false}); + const registeredServers = useRef(); + const currentServerUrl = useServerUrl(); + const isTablet = useIsTablet(); + const theme = useTheme(); + + const updateTotal = () => { + let unread = false; + let mentions = 0; + subscriptions.forEach((value) => { + unread = unread || value.unread; + mentions += value.mentions; + }); + setTotal({mentions, unread}); + }; + + const unreadsSubscription = (serverUrl: string, myChannels: MyChannelModel[]) => { + const unreads = subscriptions.get(serverUrl); + if (unreads) { + let mentions = 0; + let unread = false; + for (const myChannel of myChannels) { + mentions += myChannel.mentionsCount; + unread = unread || myChannel.isUnread; + } + + unreads.mentions = mentions; + unreads.unread = unread; + subscriptions.set(serverUrl, unreads); + updateTotal(); + } + }; + + const serversObserver = async (servers: ServersModel[]) => { + registeredServers.current = servers; + + // unsubscribe mentions from servers that were removed + const allUrls = servers.map((s) => s.url); + const subscriptionsToRemove = [...subscriptions].filter(([key]) => allUrls.indexOf(key) === -1); + for (const [key, map] of subscriptionsToRemove) { + map.subscription?.unsubscribe(); + subscriptions.delete(key); + } + + for (const server of servers) { + const {lastActiveAt, url} = server; + if (lastActiveAt && url !== currentServerUrl && !subscriptions.has(url)) { + const unreads: UnreadSubscription = { + mentions: 0, + unread: false, + }; + subscriptions.set(url, unreads); + unreads.subscription = subscribeUnreadAndMentionsByServer(url, unreadsSubscription); + } else if (subscriptions.has(url)) { + subscriptions.get(url)?.subscription?.unsubscribe(); + subscriptions.delete(url); + } + } + }; + + const onPress = useCallback(() => { + if (registeredServers.current?.length) { + const renderContent = () => { + return ( + + ); + }; + + const snapPoints = ['50%', 10]; + if (registeredServers.current.length > 3) { + snapPoints[0] = '90%'; + } + + const closeButtonId = 'close-your-servers'; + bottomSheet({ + closeButtonId, + renderContent, + snapPoints, + theme, + title: intl.formatMessage({id: 'servers.create_button', defaultMessage: 'Add a Server'}), + }); + } + }, [isTablet, theme]); + + useEffect(() => { + const subscription = subscribeAllServers(serversObserver); + + return () => { + subscription?.unsubscribe(); + subscriptions.forEach((unreads) => { + unreads.subscription?.unsubscribe(); + }); + }; + }, []); + + return ( + + ); +} + diff --git a/app/screens/home/channel_list/servers/servers_list/index.tsx b/app/screens/home/channel_list/servers/servers_list/index.tsx new file mode 100644 index 000000000..450ccc745 --- /dev/null +++ b/app/screens/home/channel_list/servers/servers_list/index.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {ListRenderItemInfo, StyleSheet, View} from 'react-native'; +import {FlatList} from 'react-native-gesture-handler'; + +import {useServerUrl} from '@context/server'; +import {useIsTablet} from '@hooks/device'; +import BottomSheetContent from '@screens/bottom_sheet/content'; + +import ServerItem from './server_item'; + +import type ServersModel from '@typings/database/models/app/servers'; + +type Props = { + servers: ServersModel[]; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + marginVertical: 4, + }, +}); + +const keyExtractor = (item: ServersModel) => item.url; + +const ServerList = ({servers}: Props) => { + const intl = useIntl(); + const isTablet = useIsTablet(); + const serverUrl = useServerUrl(); + + const onAddServer = useCallback(() => { + // eslint-disable-next-line no-console + console.log('Lets add a server'); + }, [servers]); + + const renderServer = useCallback(({item: t}: ListRenderItemInfo) => { + return ( + + ); + }, []); + + return ( + + + + + + ); +}; + +export default ServerList; diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/index.tsx b/app/screens/home/channel_list/servers/servers_list/server_item/index.tsx new file mode 100644 index 000000000..2d9215c9b --- /dev/null +++ b/app/screens/home/channel_list/servers/servers_list/server_item/index.tsx @@ -0,0 +1,257 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter, Text, View} from 'react-native'; +import {RectButton} from 'react-native-gesture-handler'; +import Swipeable from 'react-native-gesture-handler/Swipeable'; + +import CompassIcon from '@components/compass_icon'; +import ServerIcon from '@components/server_icon'; +import {Events, Navigation} from '@constants'; +import {useTheme} from '@context/theme'; +import {subscribeServerUnreadAndMentions} from '@database/subscription/unreads'; +import WebsocketManager from '@init/websocket_manager'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; +import {removeProtocol, stripTrailingSlashes} from '@utils/url'; + +import Options from './options'; + +import type ServersModel from '@typings/database/models/app/servers'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type {Subscription} from 'rxjs'; + +type Props = { + isActive: boolean; + server: ServersModel; +} + +type BadgeValues = { + isUnread: boolean; + mentions: number; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + active: { + borderColor: theme.sidebarTextActiveBorder, + borderWidth: 3, + }, + badge: { + left: 18, + top: -5, + }, + button: { + borderRadius: 8, + flex: 1, + height: 72, + justifyContent: 'center', + paddingHorizontal: 18, + }, + container: { + alignItems: 'center', + backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), + borderRadius: 8, + flexDirection: 'row', + height: 72, + marginBottom: 12, + }, + details: { + marginLeft: 14, + }, + logout: { + backgroundColor: theme.centerChannelBg, + borderRadius: 8, + height: 16, + left: 40, + position: 'absolute', + top: 11, + width: 16, + }, + nameContainer: { + alignItems: 'center', + flexDirection: 'row', + }, + name: { + color: theme.centerChannelColor, + ...typography('Body', 200, 'SemiBold'), + }, + offline: { + opacity: 0.5, + }, + row: {flexDirection: 'row'}, + unread: { + top: -2, + left: 25, + }, + url: { + color: changeOpacity(theme.centerChannelColor, 0.72), + ...typography('Body', 75, 'Regular'), + }, + websocket: { + marginLeft: 7, + marginTop: 4, + }, +})); + +const ServerItem = ({isActive, server}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const [badge, setBadge] = useState({isUnread: false, mentions: 0}); + const styles = getStyleSheet(theme); + const swipeable = useRef(); + const subscription = useRef(); + const websocketError = server.lastActiveAt ? !WebsocketManager.isConnected(server.url) : false; + let displayName = server.displayName; + if (server.url === server.displayName) { + displayName = intl.formatMessage({id: 'servers.default', defaultMessage: 'Default Server'}); + } + + const unreadsSubscription = (myChannels: MyChannelModel[]) => { + let mentions = 0; + let isUnread = false; + for (const myChannel of myChannels) { + mentions += myChannel.mentionsCount; + isUnread = isUnread || myChannel.isUnread; + } + + setBadge({isUnread, mentions}); + }; + + const containerStyle = useMemo(() => { + const style = [styles.container]; + if (isActive) { + style.push(styles.active); + } + + return style; + }, [isActive]); + + const serverStyle = useMemo(() => { + const style = [styles.row]; + if (!server.lastActiveAt) { + style.push(styles.offline); + } + + return style; + }, [server.lastActiveAt]); + + const onServerPressed = useCallback(() => { + if (isActive) { + // eslint-disable-next-line no-console + console.log('ACTIVE SERVER', server.displayName); + DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL); + return; + } + + if (server.lastActiveAt) { + // eslint-disable-next-line no-console + console.log('SWITCH TO SERVER', server.displayName); + return; + } + + // eslint-disable-next-line no-console + console.log('LOGIN TO SERVER', server.displayName); + }, [server]); + + const onSwipeableWillOpen = useCallback(() => { + DeviceEventEmitter.emit(Events.SWIPEABLE, server.url); + }, [server]); + + const renderActions = useCallback((progress) => { + return ( + + ); + }, [server]); + + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.SWIPEABLE, (url: string) => { + if (server.url !== url) { + swipeable.current?.close(); + } + }); + + return () => listener.remove(); + }, [server]); + + useEffect(() => { + if (!isActive) { + if (server.lastActiveAt && !subscription.current) { + subscription.current = subscribeServerUnreadAndMentions(server.url, unreadsSubscription); + } else if (!server.lastActiveAt && subscription.current) { + subscription.current.unsubscribe(); + subscription.current = undefined; + } + } + + return () => { + subscription.current?.unsubscribe(); + subscription.current = undefined; + }; + }, [server.lastActiveAt, isActive]); + + return ( + + + + {!server.lastActiveAt && + + + + } + + + + + {displayName} + {websocketError && + + } + + {removeProtocol(stripTrailingSlashes(server.url))} + + + + + + ); +}; + +export default ServerItem; diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/options/index.tsx b/app/screens/home/channel_list/servers/servers_list/server_item/options/index.tsx new file mode 100644 index 000000000..0628e3377 --- /dev/null +++ b/app/screens/home/channel_list/servers/servers_list/server_item/options/index.tsx @@ -0,0 +1,81 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {Animated, StyleSheet, View} from 'react-native'; + +import {useTheme} from '@context/theme'; +import {changeOpacity} from '@utils/theme'; + +import Option, {OPTION_SIZE} from './option'; + +import type ServersModel from '@typings/database/models/app/servers'; + +type Props = { + progress: Animated.AnimatedInterpolation; + server: ServersModel; +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + marginLeft: 12, + width: OPTION_SIZE * 3, + }, + left: {borderTopLeftRadius: 8, borderBottomLeftRadius: 8}, + right: {borderTopRightRadius: 8, borderBottomRightRadius: 8}, +}); + +const ServerOptions = ({progress, server}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + + const onEdit = useCallback(() => { + // eslint-disable-next-line no-console + console.log('ON EDIT'); + }, [server]); + + const onLogout = useCallback(() => { + // eslint-disable-next-line no-console + console.log('ON Logout'); + }, [server]); + + const onRemove = useCallback(() => { + // eslint-disable-next-line no-console + console.log('ON Remove'); + }, [server]); + + return ( + + + ); +}; + +export default ServerOptions; diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/options/option.tsx b/app/screens/home/channel_list/servers/servers_list/server_item/options/option.tsx new file mode 100644 index 000000000..121d14d8c --- /dev/null +++ b/app/screens/home/channel_list/servers/servers_list/server_item/options/option.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {Animated, StyleProp, Text, View, ViewStyle} from 'react-native'; +import {RectButton} from 'react-native-gesture-handler'; + +import CompassIcon from '@components/compass_icon'; +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + color: string; + icon: string; + onPress: () => void; + positionX: number; + progress: Animated.AnimatedInterpolation; + style?: StyleProp; + text: string; +} + +export const OPTION_SIZE = 72; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + centered: { + alignItems: 'center', + justifyContent: 'center', + }, + container: { + height: OPTION_SIZE, + width: OPTION_SIZE, + }, + text: { + color: theme.sidebarText, + ...typography('Body', 75, 'SemiBold'), + }, +})); + +const ServerOption = ({color, icon, onPress, positionX, progress, style, text}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + const containerStyle = useMemo(() => { + return [styles.container, {backgroundColor: color}, style]; + }, [color, style]); + const centeredStyle = useMemo(() => [styles.container, styles.centered], []); + + const trans = progress.interpolate({ + inputRange: [0, 1], + outputRange: [positionX, 0], + }); + + return ( + + + + + {text} + + + + ); +}; + +export default ServerOption; diff --git a/app/screens/home/tab_bar/home.tsx b/app/screens/home/tab_bar/home.tsx index c20e40cda..3e986237c 100644 --- a/app/screens/home/tab_bar/home.tsx +++ b/app/screens/home/tab_bar/home.tsx @@ -1,37 +1,25 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Q} from '@nozbe/watermelondb'; import React, {useEffect, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import Badge from '@components/badge'; import CompassIcon from '@components/compass_icon'; -import {MM_TABLES} from '@constants/database'; import {BOTTOM_TAB_ICON_SIZE} from '@constants/view'; -import DatabaseManager from '@database/manager'; +import {subscribeAllServers} from '@database/subscription/servers'; +import {subscribeUnreadAndMentionsByServer} from '@database/subscription/unreads'; import {changeOpacity} from '@utils/theme'; import type ServersModel from '@typings/database/models/app/servers'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; -import type {Subscription} from 'rxjs'; +import type {UnreadMessages, UnreadSubscription} from '@typings/database/subscriptions'; type Props = { isFocused: boolean; theme: Theme; } -type UnreadMessages = { - mentions: number; - unread: boolean; -}; - -type UnreadSubscription = UnreadMessages & { - subscription?: Subscription; -} - -const {SERVERS} = MM_TABLES.APP; -const {CHANNEL, MY_CHANNEL} = MM_TABLES.SERVER; const subscriptions: Map = new Map(); const style = StyleSheet.create({ @@ -51,7 +39,6 @@ const style = StyleSheet.create({ }); const Home = ({isFocused, theme}: Props) => { - const db = DatabaseManager.appDatabase?.database; const [total, setTotal] = useState({mentions: 0, unread: false}); const updateTotal = () => { @@ -69,10 +56,10 @@ const Home = ({isFocused, theme}: Props) => { if (unreads) { let mentions = 0; let unread = false; - myChannels.forEach((myChannel) => { + for (const myChannel of myChannels) { mentions += myChannel.mentionsCount; unread = unread || myChannel.isUnread; - }); + } unreads.mentions = mentions; unreads.unread = unread; @@ -82,39 +69,32 @@ const Home = ({isFocused, theme}: Props) => { }; const serversObserver = async (servers: ServersModel[]) => { - servers.forEach((server) => { - const serverUrl = server.url; - if (server.lastActiveAt) { - const sdb = DatabaseManager.serverDatabases[serverUrl]; - if (sdb?.database) { - if (!subscriptions.has(serverUrl)) { - const unreads: UnreadSubscription = { - mentions: 0, - unread: false, - }; - subscriptions.set(serverUrl, unreads); - unreads.subscription = sdb.database. - get(MY_CHANNEL). - query(Q.on(CHANNEL, Q.where('delete_at', Q.eq(0)))). - observeWithColumns(['mentions_count', 'has_unreads']). - subscribe(unreadsSubscription.bind(undefined, serverUrl)); - } - } + // unsubscribe mentions from servers that were removed + const allUrls = servers.map((s) => s.url); + const subscriptionsToRemove = [...subscriptions].filter(([key]) => allUrls.indexOf(key) === -1); + for (const [key, map] of subscriptionsToRemove) { + map.subscription?.unsubscribe(); + subscriptions.delete(key); + } - // subscribe and listen for unreads and mentions - } else if (subscriptions.has(serverUrl)) { - // logout from server, remove the subscription - subscriptions.delete(serverUrl); + for (const server of servers) { + const {lastActiveAt, url} = server; + if (lastActiveAt && !subscriptions.has(url)) { + const unreads: UnreadSubscription = { + mentions: 0, + unread: false, + }; + subscriptions.set(url, unreads); + unreads.subscription = subscribeUnreadAndMentionsByServer(url, unreadsSubscription); + } else if (subscriptions.has(url)) { + subscriptions.get(url)?.subscription?.unsubscribe(); + subscriptions.delete(url); } - }); + } }; useEffect(() => { - const subscription = db?. - get(SERVERS). - query(). - observeWithColumns(['last_active_at']). - subscribe(serversObserver); + const subscription = subscribeAllServers(serversObserver); return () => { subscription?.unsubscribe(); diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index fbf27d235..40c38f339 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -545,13 +545,14 @@ export async function dismissOverlay(componentId: string) { type BottomSheetArgs = { closeButtonId: string; + initialSnapIndex?: number; renderContent: () => JSX.Element; - snapPoints: number[]; + snapPoints: Array; theme: Theme; title: string; } -export async function bottomSheet({title, renderContent, snapPoints, theme, closeButtonId}: BottomSheetArgs) { +export async function bottomSheet({title, renderContent, snapPoints, initialSnapIndex = 0, theme, closeButtonId}: BottomSheetArgs) { const {isSplitView} = await isRunningInSplitView(); const isTablet = Device.IS_TABLET && !isSplitView; @@ -559,6 +560,7 @@ export async function bottomSheet({title, renderContent, snapPoints, theme, clos const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor); showModal(Screens.BOTTOM_SHEET, title, { closeButtonId, + initialSnapIndex, renderContent, snapPoints, }, { @@ -581,6 +583,7 @@ export async function bottomSheet({title, renderContent, snapPoints, theme, clos }); } else { showModalOverCurrentContext(Screens.BOTTOM_SHEET, { + initialSnapIndex, renderContent, snapPoints, }, {swipeToDismiss: true}); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 2361ea1bf..ae42daa39 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -317,6 +317,11 @@ "screens.channel_details": "Channel Details", "screens.channel_edit": "Edit Channel", "search_bar.search": "Search", + "servers.create_button": "Add a Server", + "servers.default": "Default Server", + "servers.edit": "Edit", + "servers.logout": "Log out", + "servers.remove": "Remove", "status_dropdown.set_away": "Away", "status_dropdown.set_dnd": "Do Not Disturb", "status_dropdown.set_offline": "Offline", @@ -332,5 +337,6 @@ "user.settings.general.lastName": "Last Name", "user.settings.general.nickname": "Nickname", "user.settings.general.position": "Position", - "user.settings.general.username": "Username" + "user.settings.general.username": "Username", + "your.servers": "Your Servers" } diff --git a/types/database/subscriptions/index.ts b/types/database/subscriptions/index.ts new file mode 100644 index 000000000..fd192d301 --- /dev/null +++ b/types/database/subscriptions/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Subscription} from 'rxjs'; + +export type UnreadMessages = { + mentions: number; + unread: boolean; +}; + +export type UnreadSubscription = UnreadMessages & { + subscription?: Subscription; +};