[Gekidou] Multi-Server support UI (#5912)

* Multi-Server support UI

* feedback review

* Apply suggestions from code review

Co-authored-by: Avinash Lingaloo <avinashlng1080@gmail.com>

Co-authored-by: Avinash Lingaloo <avinashlng1080@gmail.com>
This commit is contained in:
Elias Nahum
2022-01-28 09:51:30 -03:00
committed by GitHub
parent dff4f91441
commit d14ce66897
25 changed files with 1092 additions and 218 deletions

View File

@@ -0,0 +1,166 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Server Icon Server Icon Component should match snapshot 1`] = `
<View>
<View
accessibilityState={
Object {
"disabled": true,
}
}
accessible={true}
collapsable={false}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"opacity": 1,
}
}
>
<Icon
color="rgba(255,255,255,0.56)"
name="server-variant"
size={24}
/>
</View>
</View>
`;
exports[`Server Icon Server Icon Component should match snapshot with mentions 1`] = `
<View>
<View
accessibilityState={
Object {
"disabled": true,
}
}
accessible={true}
collapsable={false}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"opacity": 1,
}
}
>
<Icon
color="rgba(255,255,255,0.56)"
name="server-variant"
size={24}
/>
<Text
collapsable={false}
numberOfLines={1}
style={
Object {
"alignSelf": "flex-end",
"backgroundColor": "#ffffff",
"borderColor": "#14213e",
"borderRadius": 11,
"borderWidth": 2,
"color": "#1e325c",
"fontFamily": "OpenSans-Bold",
"fontSize": 12,
"height": 22,
"left": 25,
"lineHeight": 16.5,
"minWidth": 26,
"opacity": 1,
"overflow": "hidden",
"paddingHorizontal": 5,
"position": "absolute",
"textAlign": "center",
"top": -1,
"transform": Array [
Object {
"scale": 1,
},
],
}
}
>
1
</Text>
</View>
</View>
`;
exports[`Server Icon Server Icon Component should match snapshot with unreads 1`] = `
<View>
<View
accessibilityState={
Object {
"disabled": true,
}
}
accessible={true}
collapsable={false}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"opacity": 1,
}
}
>
<Icon
color="rgba(255,255,255,0.56)"
name="server-variant"
size={24}
/>
<Text
collapsable={false}
numberOfLines={1}
style={
Object {
"alignSelf": "flex-end",
"backgroundColor": "#ffffff",
"borderColor": "#14213e",
"borderRadius": 6,
"borderWidth": 2,
"color": "#1e325c",
"fontFamily": "OpenSans-Bold",
"fontSize": 12,
"height": 12,
"left": 25,
"lineHeight": 16.5,
"minWidth": 12,
"opacity": 1,
"overflow": "hidden",
"paddingHorizontal": 0,
"position": "absolute",
"textAlign": "center",
"top": 5,
"transform": Array [
Object {
"scale": 1,
},
],
}
}
>
</Text>
</View>
</View>
`;

View File

@@ -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<ViewStyle>;
hasUnreads: boolean;
iconColor?: string;
mentionCount: number;
onPress?: () => void;
size?: number;
style?: StyleProp<ViewStyle>;
unreadStyle?: StyleProp<ViewStyle>;
}
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 (
<View style={style}>
<TouchableWithFeedback
disabled={onPress === undefined}
onPress={onPress}
type='opacity'
>
<CompassIcon
size={size}
name='server-variant'
color={iconColor || changeOpacity(theme.sidebarHeaderTextColor, 0.56)}
/>
<Badge
borderColor={badgeBorderColor || theme.sidebarTeamBarBg}
backgroundColor={badgeBackgroundColor}
color={badgeColor}
visible={hasBadge}
style={memoizedStyle}
value={count}
/>
</TouchableWithFeedback>
</View>
);
}

View File

@@ -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(
<Icon
hasUnreads={false}
mentionCount={0}
/>,
);
expect(toJSON()).toMatchSnapshot();
});
test('Server Icon Component should match snapshot with unreads', () => {
const {toJSON} = renderWithIntlAndTheme(
<Icon
hasUnreads={true}
mentionCount={0}
/>,
);
expect(toJSON()).toMatchSnapshot();
});
test('Server Icon Component should match snapshot with mentions', () => {
const {toJSON} = renderWithIntlAndTheme(
<Icon
hasUnreads={false}
mentionCount={1}
/>,
);
expect(toJSON()).toMatchSnapshot();
});
});

View File

@@ -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 (

View File

@@ -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';

View File

@@ -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';

View File

@@ -16,4 +16,5 @@ export default keyMirror({
TEAM_LOAD_ERROR: null,
USER_TYPING: null,
USER_STOP_TYPING: null,
SWIPEABLE: null,
});

View File

@@ -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<T>(Component: ComponentType<T>): ComponentType<T> {
return function ServerDatabaseComponent(props) {
const [state, setState] = useState<State | undefined>();
const db = DatabaseManager.appDatabase?.database;
const observer = (servers: ServersModel[]) => {
const server = servers?.length ? servers.reduce((a, b) =>
@@ -46,11 +43,7 @@ export function withServerDatabase<T>(Component: ComponentType<T>): 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();

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 && <ServerIcon/>}
{canAddOtherServers && <Servers/>}
<Animated.View
style={[styles.content, animated]}
>

View File

@@ -1,26 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Server Icon Component should match snapshot 1`] = `
<View
style={
Object {
"alignItems": "center",
"flex": 0,
"height": 40,
"justifyContent": "center",
"left": 16,
"overflow": "hidden",
"position": "absolute",
"top": 10,
"width": 40,
"zIndex": 10,
}
}
>
<Icon
color="rgba(255,255,255,0.56)"
name="server-variant"
size={24}
/>
</View>
`;

View File

@@ -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(
<Icon/>,
);
expect(toJSON()).toMatchSnapshot();
});

View File

@@ -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 (
<View style={styles.icon}>
<CompassIcon
size={24}
name='server-variant'
color={changeOpacity(theme.sidebarHeaderTextColor, 0.56)}
/>
</View>
);
}

View File

@@ -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<string, UnreadSubscription> = 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<UnreadMessages>({mentions: 0, unread: false});
const registeredServers = useRef<ServersModel[]|undefined>();
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 (
<ServerList servers={registeredServers.current!}/>
);
};
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 (
<ServerIcon
hasUnreads={total.unread}
mentionCount={total.mentions}
onPress={onPress}
style={styles.icon}
/>
);
}

View File

@@ -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<ServersModel>) => {
return (
<ServerItem
isActive={t.url === serverUrl}
server={t}
/>
);
}, []);
return (
<BottomSheetContent
buttonIcon='plus'
buttonText={intl.formatMessage({id: 'servers.create_button', defaultMessage: 'Add a Server'})}
onPress={onAddServer}
showButton={true}
showTitle={!isTablet}
title={intl.formatMessage({id: 'your.servers', defaultMessage: 'Your Servers'})}
>
<View style={styles.container}>
<FlatList
data={servers}
renderItem={renderServer}
keyExtractor={keyExtractor}
contentContainerStyle={styles.contentContainer}
/>
</View>
</BottomSheetContent>
);
};
export default ServerList;

View File

@@ -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<BadgeValues>({isUnread: false, mentions: 0});
const styles = getStyleSheet(theme);
const swipeable = useRef<Swipeable>();
const subscription = useRef<Subscription|undefined>();
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 (
<Options
progress={progress}
server={server}
/>
);
}, [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 (
<Swipeable
renderRightActions={renderActions}
friction={2}
onSwipeableWillOpen={onSwipeableWillOpen}
// @ts-expect-error legacy ref
ref={swipeable}
rightThreshold={40}
>
<View
style={containerStyle}
>
<RectButton
onPress={onServerPressed}
style={styles.button}
rippleColor={changeOpacity(theme.centerChannelColor, 0.16)}
>
{!server.lastActiveAt &&
<View style={styles.logout}>
<CompassIcon
name='minus-circle'
size={16}
color={theme.dndIndicator}
/>
</View>
}
<View style={serverStyle}>
<ServerIcon
badgeBackgroundColor={theme.mentionColor}
badgeBorderColor={theme.mentionBg}
badgeColor={theme.mentionBg}
badgeStyle={styles.badge}
iconColor={changeOpacity(theme.centerChannelColor, 0.56)}
hasUnreads={badge.isUnread}
mentionCount={badge.mentions}
size={36}
unreadStyle={styles.unread}
/>
<View style={styles.details}>
<View style={styles.nameContainer}>
<Text style={styles.name}>{displayName}</Text>
{websocketError &&
<CompassIcon
name='information-outline'
size={14.4}
color={theme.dndIndicator}
style={styles.websocket}
/>
}
</View>
<Text style={styles.url}>{removeProtocol(stripTrailingSlashes(server.url))}</Text>
</View>
</View>
</RectButton>
</View>
</Swipeable>
);
};
export default ServerItem;

View File

@@ -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 (
<View style={styles.container}>
<Option
color={changeOpacity(theme.centerChannelColor, 0.48)}
icon='pencil-outline'
onPress={onEdit}
positionX={OPTION_SIZE * 3}
progress={progress}
style={styles.left}
text={intl.formatMessage({id: 'servers.edit', defaultMessage: 'Edit'})}
/>
<Option
color={theme.dndIndicator}
icon='trash-can-outline'
onPress={onRemove}
positionX={OPTION_SIZE * 2}
progress={progress}
text={intl.formatMessage({id: 'servers.remove', defaultMessage: 'Remove'})}
/>
<Option
color={theme.newMessageSeparator}
icon='exit-to-app'
onPress={onLogout}
positionX={OPTION_SIZE}
progress={progress}
style={styles.right}
text={intl.formatMessage({id: 'servers.logout', defaultMessage: 'Log out'})}
/>
</View>
);
};
export default ServerOptions;

View File

@@ -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<ViewStyle>;
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 (
<Animated.View style={{transform: [{translateX: trans}]}}>
<View style={containerStyle}>
<RectButton
style={centeredStyle}
onPress={onPress}
>
<CompassIcon
color={theme.sidebarText}
name={icon}
size={24}
/>
<Text style={styles.text}>{text}</Text>
</RectButton>
</View>
</Animated.View>
);
};
export default ServerOption;

View File

@@ -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<string, UnreadSubscription> = 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<UnreadMessages>({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();

View File

@@ -545,13 +545,14 @@ export async function dismissOverlay(componentId: string) {
type BottomSheetArgs = {
closeButtonId: string;
initialSnapIndex?: number;
renderContent: () => JSX.Element;
snapPoints: number[];
snapPoints: Array<number | string>;
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});