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;
+};