Gekidou teamsidebar (#5718)

This commit is contained in:
Daniel Espino García 2021-10-15 09:55:02 +02:00 committed by GitHub
parent 46470e0b5d
commit e6f9b0e258
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 2214 additions and 62 deletions

35
app/actions/local/team.ts Normal file
View file

@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {NativeModules} from 'react-native';
import {fetchPostsForChannel} from '@actions/remote/post';
import {Device} from '@constants';
import DatabaseManager from '@database/manager';
import {queryCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {queryLastChannelFromTeam} from '@queries/servers/team';
const {MattermostManaged} = NativeModules;
const isRunningInSplitView = MattermostManaged.isRunningInSplitView;
export const handleTeamChange = async (serverUrl: string, teamId: string) => {
const {operator, database} = DatabaseManager.serverDatabases[serverUrl];
const currentTeamId = await queryCurrentTeamId(database);
if (currentTeamId === teamId) {
return;
}
let channelId = '';
if (Device.IS_TABLET) {
const {isSplitView} = await isRunningInSplitView();
if (!isSplitView) {
channelId = await queryLastChannelFromTeam(database, teamId);
if (channelId) {
fetchPostsForChannel(serverUrl, channelId);
}
}
}
setCurrentTeamAndChannelId(operator, teamId, channelId);
};

View file

@ -5,11 +5,13 @@ import {Model} from '@nozbe/watermelondb';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {prepareMyChannelsForTeam, queryDefaultChannelForTeam} from '@queries/servers/channel';
import {queryWebSocketLastDisconnected} from '@queries/servers/system';
import {prepareMyTeams, queryMyTeamById} from '@queries/servers/team';
import {isTablet} from '@utils/helpers';
import {fetchMyChannelsForTeam} from './channel';
import {fetchPostsForUnreadChannels} from './post';
import {fetchPostsForChannel, fetchPostsForUnreadChannels} from './post';
import {fetchRolesIfNeeded} from './role';
import {forceLogoutIfNecessary} from './session';
@ -36,6 +38,7 @@ export const addUserToTeam = async (serverUrl: string, teamId: string, userId: s
if (!fetchOnly) {
fetchRolesIfNeeded(serverUrl, member.roles.split(' '));
const {channels, memberships: channelMembers} = await fetchMyChannelsForTeam(serverUrl, teamId, false, 0, true);
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
const myTeams: MyTeam[] = [{
@ -46,6 +49,7 @@ export const addUserToTeam = async (serverUrl: string, teamId: string, userId: s
const models = await Promise.all([
operator.handleMyTeam({myTeams, prepareRecordsOnly: true}),
operator.handleTeamMemberships({teamMemberships: [member], prepareRecordsOnly: true}),
prepareMyChannelsForTeam(operator, teamId, channels || [], channelMembers || []),
]);
if (models.length) {
@ -54,6 +58,13 @@ export const addUserToTeam = async (serverUrl: string, teamId: string, userId: s
await operator.batchRecords(flattenedModels);
}
}
if (await isTablet()) {
const channel = await queryDefaultChannelForTeam(operator.database, teamId);
if (channel) {
fetchPostsForChannel(serverUrl, channel.id);
}
}
}
}
@ -103,6 +114,31 @@ export const fetchMyTeams = async (serverUrl: string, fetchOnly = false): Promis
}
};
export const fetchAllTeams = async (serverUrl: string, fetchOnly = false): Promise<MyTeamsRequest> => {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const teams = await client.getTeams();
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
await operator.handleTeam({prepareRecordsOnly: false, teams});
}
}
return {teams};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
};
export const fetchTeamsChannelsAndUnreadPosts = async (serverUrl: string, teams: Team[], memberships: TeamMembership[], excludeTeamId?: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {

View file

@ -48,7 +48,7 @@ export default function Badge({
Animated.timing(opacity, {
toValue: visible ? 1 : 0,
duration: 150,
duration: 200,
useNativeDriver: true,
}).start(({finished}) => {
if (finished && !visible) {

View file

@ -0,0 +1,126 @@
// 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 {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 {Device, Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useSplitView} from '@hooks/device';
import {showModal, showModalOverCurrentContext} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import AddTeamSlideUp from './add_team_slide_up';
import type TeamModel from '@typings/database/models/servers/team';
const ITEM_HEIGHT = 72;
const CREATE_HEIGHT = 97;
const HEADER_HEIGHT = 66;
const CONTAINER_HEIGHT = 392;
type Props = {
canCreateTeams: boolean;
otherTeams: TeamModel[];
}
export default function AddTeam({canCreateTeams, otherTeams}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const dimensions = useWindowDimensions();
const intl = useIntl();
const isSplitView = useSplitView();
const isTablet = Device.IS_TABLET && !isSplitView;
const maxHeight = Math.round((dimensions.height * 0.9));
const onPress = useCallback(preventDoubleTap(() => {
const renderContent = () => {
return (
<AddTeamSlideUp
otherTeams={otherTeams}
canCreateTeams={canCreateTeams}
showTitle={!isTablet && Boolean(otherTeams.length)}
/>
);
};
let height = CONTAINER_HEIGHT;
if (otherTeams.length) {
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});
}
}), [canCreateTeams, otherTeams, isTablet, theme]);
return (
<View style={styles.container}>
<TouchableWithFeedback
onPress={onPress}
type='opacity'
style={styles.touchable}
>
<CompassIcon
size={28}
name='plus'
color={changeOpacity(theme.buttonColor, 0.64)}
/>
</TouchableWithFeedback>
</View>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 0,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.64),
borderRadius: 10,
height: 48,
width: 48,
marginTop: 6,
marginBottom: 12,
marginHorizontal: 12,
overflow: 'hidden',
},
touchable: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
},
};
});

View file

@ -0,0 +1,41 @@
// 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 {DeviceEventEmitter} from 'react-native';
import {Navigation} from '@constants';
import BottomSheetContent from '@screens/bottom_sheet/content';
import TeamList from './team_list';
import type TeamModel from '@typings/database/models/servers/team';
type Props = {
otherTeams: TeamModel[];
canCreateTeams: boolean;
showTitle?: boolean;
}
export default function AddTeamSlideUp({otherTeams, canCreateTeams, showTitle = true}: Props) {
const intl = useIntl();
const onPressCreate = useCallback(() => {
//TODO Create team screen
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
}, []);
return (
<BottomSheetContent
buttonIcon='plus'
buttonText={intl.formatMessage({id: 'mobile.add_team.create_team', defaultMessage: 'Create a New Team'})}
onPress={onPressCreate}
showButton={canCreateTeams}
showTitle={showTitle}
title={intl.formatMessage({id: 'mobile.add_team.join_team', defaultMessage: 'Join Another Team'})}
>
<TeamList teams={otherTeams}/>
</BottomSheetContent>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 82 KiB

View file

@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {ListRenderItemInfo, View} from 'react-native';
import {FlatList} from 'react-native-gesture-handler';
import {makeStyleSheetFromTheme} from '@app/utils/theme';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import TeamListItem from './team_list_item';
import type TeamModel from '@typings/database/models/servers/team';
const Empty = require('./no_teams.svg').default;
type Props = {
teams: TeamModel[];
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flexShrink: 1,
},
contentContainer: {
marginVertical: 4,
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontFamily: 'Metropolis',
fontSize: 20,
color: theme.centerChannelColor,
lineHeight: 28,
marginTop: 16,
},
description: {
fontFamily: 'Open Sans',
fontSize: 16,
color: theme.centerChannelColor,
lineHeight: 24,
marginTop: 8,
maxWidth: 334,
},
}));
const renderTeam = ({item: t}: ListRenderItemInfo<TeamModel>) => {
return (
<TeamListItem
team={t}
/>
);
};
const keyExtractor = (item: TeamModel) => item.id;
export default function TeamList({teams}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
if (teams.length) {
return (
<View style={styles.container}>
<FlatList
data={teams}
renderItem={renderTeam}
keyExtractor={keyExtractor}
contentContainerStyle={styles.contentContainer}
/>
</View>
);
}
return (
<View style={styles.empty}>
<Empty/>
<FormattedText
id='team_list.no_other_teams.title'
defaultMessage='No additional teams to join'
style={styles.title}
/>
<FormattedText
id='team_list.no_other_teams.description'
defaultMessage='To join another team, ask a Team Admin for an invitation, or create your own team.'
style={styles.description}
/>
</View>
);
}

View file

@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import TeamListItem from './team_list_item';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {SYSTEM}} = MM_TABLES;
const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUserId: database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap((currentUserId) => of$(currentUserId.value))),
}));
export default withDatabase(withSystem(TeamListItem));

View file

@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {DeviceEventEmitter, Text, View} from 'react-native';
import {addUserToTeam} from '@actions/remote/team';
import TeamIcon from '@components/team_sidebar/team_list/team_item/team_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Navigation} from '@constants';
import {useServerUrl} from '@context/server_url';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type TeamModel from '@typings/database/models/servers/team';
type Props = {
team: TeamModel;
currentUserId: string;
}
export default function TeamListItem({team, currentUserId}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const onPress = useCallback(async () => {
await addUserToTeam(serverUrl, team.id, currentUserId);
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
}, []);
return (
<View style={styles.container}>
<TouchableWithFeedback
onPress={onPress}
type='opacity'
style={styles.touchable}
>
<View style={styles.icon_container}>
<TeamIcon
id={team.id}
displayName={team.displayName}
lastIconUpdate={team.lastTeamIconUpdatedAt}
selected={false}
/>
</View>
<Text
style={styles.text}
numberOfLines={1}
>{team.displayName}</Text>
</TouchableWithFeedback>
</View>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
height: 64,
marginVertical: 2,
},
touchable: {
display: 'flex',
flexDirection: 'row',
borderRadius: 4,
alignItems: 'center',
height: '100%',
width: '100%',
},
text: {
color: theme.centerChannelColor,
fontSize: 16,
lineHeight: 24,
marginLeft: 16,
},
icon_container: {
width: 40,
height: 40,
},
};
});

View file

@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Permissions} from '@app/constants';
import {hasPermission} from '@app/utils/role';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import TeamSidebar from './team_sidebar';
import type {WithDatabaseArgs} from '@typings/database/database';
import type MyTeam from '@typings/database/models/servers/my_team';
import type RoleModel from '@typings/database/models/servers/role';
import type SystemModel from '@typings/database/models/servers/system';
import type TeamModel from '@typings/database/models/servers/team';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {SYSTEM, MY_TEAM, TEAM, USER, ROLE}} = MM_TABLES;
type PropsInput = WithDatabaseArgs & {
currentUser: UserModel;
}
const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUser: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap((currentUserId: SystemModel) => database.get(USER).findAndObserve(currentUserId.value))),
}));
const withTeams = withObservables([], ({currentUser, database}: PropsInput) => {
const rolesArray = [...currentUser.roles.split(' ')];
const roles = database.get<RoleModel>(ROLE).query(Q.where('name', Q.oneOf(rolesArray))).observe();
const canCreateTeams = roles.pipe(switchMap((r) => of$(hasPermission(r, Permissions.CREATE_TEAM, false))));
const otherTeams = database.get<MyTeam>(MY_TEAM).query().observe().pipe(
switchMap((mm) => {
// eslint-disable-next-line max-nested-callbacks
const ids = mm.map((m) => m.id);
return database.get<TeamModel>(TEAM).query(Q.where('id', Q.notIn(ids))).observe();
}),
);
return {
canCreateTeams,
otherTeams,
};
});
export default withDatabase(withSystem(withTeams(TeamSidebar)));

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
// TODO PLACEHOLDER
export default function ServerIcon() {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View style={styles.container}>
<CompassIcon
size={24}
name='server-variant'
color={changeOpacity(theme.buttonColor, 0.56)}
/>
</View>
);
}
const getStyleSheet = makeStyleSheetFromTheme(() => {
return {
container: {
flex: 0,
borderRadius: 10,
height: 60,
width: '100%',
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
},
};
});

View file

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-nested-callbacks */
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$, combineLatest} from 'rxjs';
import {switchMap, map} from 'rxjs/operators';
import {Preferences} from '@constants';
import {MM_TABLES} from '@constants/database';
import TeamList from './team_list';
import type {WithDatabaseArgs} from '@typings/database/database';
import type MyTeamModel from '@typings/database/models/servers/my_team';
import type PreferenceModel from '@typings/database/models/servers/preference';
import type TeamModel from '@typings/database/models/servers/team';
const {SERVER: {MY_TEAM, PREFERENCE, TEAM}} = MM_TABLES;
const withTeams = withObservables([], ({database}: WithDatabaseArgs) => {
const myTeams = database.get<MyTeamModel>(MY_TEAM).query().observe();
const teamIds = database.get<TeamModel>(TEAM).query(
Q.on(MY_TEAM, Q.where('id', Q.notEq(''))),
).observe().pipe(
map((ts) => ts.map((t) => ({id: t.id, displayName: t.displayName}))),
);
const order = database.get<PreferenceModel>(PREFERENCE).query(
Q.where('category', Preferences.TEAMS_ORDER),
).observe().pipe(
switchMap((p) => (p.length ? of$(p[0].value.split(',')) : of$([]))),
);
const myOrderedTeams = combineLatest([myTeams, order, teamIds]).pipe(
map(([ts, o, tids]) => {
let ids: string[] = o;
if (!o.length) {
ids = tids.
sort((a, b) => a.displayName.toLocaleLowerCase().localeCompare(b.displayName.toLocaleLowerCase())).
map((t) => t.id);
}
const indexes: {[x: string]: number} = {};
const originalIndexes: {[x: string]: number} = {};
ids.forEach((v, i) => {
indexes[v] = i;
});
ts.forEach((t, i) => {
originalIndexes[t.id] = i;
});
return ts.sort((a, b) => {
if ((indexes[a.id] != null) || (indexes[b.id] != null)) {
return (indexes[a.id] ?? -1) - (indexes[b.id] ?? -1);
}
return (originalIndexes[a.id] - originalIndexes[b.id]);
});
}),
);
return {
myOrderedTeams,
};
});
export default withDatabase(withTeams(TeamList));

View file

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {MyChannelModel} from '@database/models/server';
import TeamItem from './team_item';
import type {WithDatabaseArgs} from '@typings/database/database';
import type MyTeamModel from '@typings/database/models/servers/my_team';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {SYSTEM, MY_CHANNEL, CHANNEL}} = MM_TABLES;
const withSystem = withObservables([], ({database}: WithDatabaseArgs) => {
const currentTeamId = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe(
switchMap((t) => of$(t.value)),
);
return {
currentTeamId,
};
});
type WithTeamsArgs = WithDatabaseArgs & {
myTeam: MyTeamModel;
}
const withTeams = withObservables(['myTeam'], ({myTeam, database}: WithTeamsArgs) => {
const myChannels = database.get<MyChannelModel>(MY_CHANNEL).query(Q.on(CHANNEL, Q.and(Q.where('delete_at', Q.eq(0)), Q.where('team_id', Q.eq(myTeam.id))))).observeWithColumns(['mentions_count', 'message_count']);
const mentionCount = myChannels.pipe(
// eslint-disable-next-line max-nested-callbacks
switchMap((val) => of$(val.reduce((acc, v) => acc + v.mentionsCount, 0))),
);
const hasUnreads = myChannels.pipe(
// eslint-disable-next-line max-nested-callbacks
switchMap((val) => of$(val.reduce((acc, v) => acc + v.messageCount, 0) > 0)),
);
return {
team: myTeam.team.observe(),
mentionCount,
hasUnreads,
};
});
export default withDatabase(withSystem(withTeams(TeamItem)));

View file

@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {View, Text} from 'react-native';
import FastImage from 'react-native-fast-image';
import {useServerUrl} from '@context/server_url';
import {useTheme} from '@context/theme';
import NetworkManager from '@init/network_manager';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
id: string;
lastIconUpdate: number;
displayName: string;
selected: boolean;
}
export default function TeamIcon({id, lastIconUpdate, displayName, selected}: Props) {
const [imageError, setImageError] = useState(false);
const ref = useRef<View>(null);
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const client = NetworkManager.getClient(serverUrl);
useEffect(() =>
setImageError(false)
, [id, lastIconUpdate]);
const handleImageError = useCallback(() => {
if (ref.current) {
setImageError(true);
}
}, []);
let teamIconContent;
if (imageError || !lastIconUpdate) {
teamIconContent = (
<Text
style={styles.text}
>
{displayName?.substr(0, 2).toUpperCase()}
</Text>
);
} else {
teamIconContent = (
<FastImage
style={styles.image}
source={{uri: `${serverUrl}${client.getTeamIconUrl(id, lastIconUpdate)}`}}
onError={handleImageError}
/>
);
}
return (
<View
style={selected ? styles.containerSelected : styles.container}
ref={ref}
>
{teamIconContent}
</View>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.centerChannelColor,
borderRadius: 10,
},
containerSelected: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.centerChannelColor,
borderRadius: 6,
},
text: {
color: theme.sidebarText,
fontFamily: 'OpenSans',
fontWeight: 'bold',
fontSize: 15,
},
image: {
borderRadius: 6,
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'white',
},
};
});

View file

@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import {handleTeamChange} from '@actions/local/team';
import Badge from '@components/badge';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useServerUrl} from '@context/server_url';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import TeamIcon from './team_icon';
import type TeamModel from '@typings/database/models/servers/team';
type Props = {
team: TeamModel;
hasUnreads: boolean;
mentionCount: number;
currentTeamId: string;
}
export default function TeamItem({team, hasUnreads, mentionCount, currentTeamId}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const selected = team.id === currentTeamId;
const hasBadge = Boolean(mentionCount || hasUnreads);
let mentionText = mentionCount ? mentionCount.toString() : '';
let left = 35;
switch (true) {
case mentionCount > 99:
mentionText = '99+';
left = 20;
break;
case mentionCount > 9:
left = 26;
break;
}
return (
<>
<View style={selected ? styles.containerSelected : styles.container}>
<TouchableWithFeedback
onPress={() => handleTeamChange(serverUrl, team.id)}
type='opacity'
>
<TeamIcon
displayName={team.displayName}
id={team.id}
lastIconUpdate={team.lastTeamIconUpdatedAt}
selected={selected}
/>
</TouchableWithFeedback>
</View>
<Badge
visible={hasBadge && !selected}
style={mentionCount > 0 ? [styles.mentions, {left}] : styles.unread}
size={mentionCount > 0 ? 16 : 12}
>
{mentionText}
</Badge>
</>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
height: 48,
width: 48,
flex: 0,
borderRadius: 10,
marginVertical: 6,
overflow: 'hidden',
},
containerSelected: {
height: 48,
width: 48,
padding: 3,
borderWidth: 3,
borderColor: theme.sidebarTextActiveBorder,
borderRadius: 10,
marginVertical: 6,
},
unread: {
left: 40,
top: 3,
borderColor: theme.sidebarTeamBarBg,
borderWidth: 2,
backgroundColor: theme.mentionBg,
width: 12,
},
mentions: {
top: 1,
fontSize: 12,
fontWeight: 'bold',
fontFamily: 'OpenSans',
lineHeight: 15,
borderColor: theme.sidebarTeamBarBg,
alignItems: 'center',
borderWidth: 2,
minWidth: 22,
height: 18,
borderRadius: 9,
backgroundColor: theme.mentionBg,
color: theme.mentionColor,
},
};
});

View file

@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FlatList, ListRenderItemInfo, StyleSheet, View} from 'react-native';
import TeamItem from './team_item';
import type MyTeamModel from '@typings/database/models/servers/my_team';
type Props = {
myOrderedTeams: MyTeamModel[];
}
const keyExtractor = (item: MyTeamModel) => item.id;
const renderTeam = ({item: t}: ListRenderItemInfo<MyTeamModel>) => {
return (
<TeamItem
myTeam={t}
/>
);
};
export default function TeamList({myOrderedTeams}: Props) {
return (
<View style={styles.container}>
<FlatList
bounces={false}
contentContainerStyle={styles.contentContainer}
data={myOrderedTeams}
fadingEdgeLength={36}
keyExtractor={keyExtractor}
renderItem={renderTeam}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexShrink: 1,
},
contentContainer: {
alignItems: 'center',
marginVertical: 6,
},
});

View file

@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {View} from 'react-native';
import {fetchAllTeams} from '@actions/remote/team';
import {TEAM_SIDEBAR_WIDTH} from '@constants/view';
import {useServerUrl} from '@context/server_url';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import AddTeam from './add_team/add_team';
import ServerIcon from './server_icon/server_icon';
import TeamList from './team_list';
import type TeamModel from '@typings/database/models/servers/team';
type Props = {
canCreateTeams: boolean;
otherTeams: TeamModel[];
}
export default function TeamSidebar({canCreateTeams, otherTeams}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
useEffect(() => {
fetchAllTeams(serverUrl);
}, [serverUrl]);
const showAddTeam = canCreateTeams || otherTeams.length > 0;
return (
<View style={styles.container}>
<ServerIcon/>
<View style={styles.listContainer}>
<TeamList/>
{showAddTeam && (
<AddTeam
canCreateTeams={canCreateTeams}
otherTeams={otherTeams}
/>
)}
</View>
</View>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
width: TEAM_SIDEBAR_WIDTH,
height: '100%',
backgroundColor: theme.sidebarBg,
display: 'flex',
},
listContainer: {
backgroundColor: theme.sidebarTeamBarBg,
borderTopRightRadius: 12,
flex: 1,
},
};
});

View file

@ -42,6 +42,7 @@ export const SMALL_BADGE_MAX_WIDTH = 26;
export const MAX_BADGE_RIGHT_POSITION = -13;
export const LARGE_BADGE_RIGHT_POSITION = -11;
export const SMALL_BADGE_RIGHT_POSITION = -9;
export const TEAM_SIDEBAR_WIDTH = 72;
export const TABLET = {
SIDEBAR_WIDTH: 320,
@ -137,4 +138,5 @@ export default {
IOS_HORIZONTAL_LANDSCAPE: 44,
INDICATOR_BAR_HEIGHT,
TABLET,
TEAM_SIDEBAR_WIDTH,
};

View file

@ -57,8 +57,8 @@ export default class ChannelModel extends Model {
/** A CHANNEL can contain multiple POST (relationship is 1:N) */
[POST]: {type: 'has_many', foreignKey: 'channel_id'},
/** A CHANNEL can contain multiple POST (relationship is 1:N) */
[MY_CHANNEL]: {type: 'has_many', foreignKey: 'channel_id'},
/** A CHANNEL can be associated to one MY_CHANNEL (relationship is 1:1) */
[MY_CHANNEL]: {type: 'has_many', foreignKey: 'id'},
/** A TEAM can be associated to CHANNEL (relationship is 1:N) */
[TEAM]: {type: 'belongs_to', key: 'team_id'},

View file

@ -3,7 +3,7 @@
import {Relation} from '@nozbe/watermelondb';
import {field, relation} from '@nozbe/watermelondb/decorators';
import Model from '@nozbe/watermelondb/Model';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
@ -18,6 +18,10 @@ export default class MyTeamModel extends Model {
/** table (name) : MyTeam */
static table = MY_TEAM;
static associations: Associations = {
[TEAM]: {type: 'belongs_to', key: 'id'},
};
/** roles : The different permissions that this user has in the team, concatenated together with comma to form a single string. */
@field('roles') roles!: string;

View file

@ -42,6 +42,9 @@ export default class TeamModel extends Model {
/** A TEAM has a 1:N relationship with GROUPS_TEAM. A TEAM can possess multiple groups */
[GROUPS_TEAM]: {type: 'has_many', foreignKey: 'team_id'},
/** A TEAM can be associated to one MY_TEAM (relationship is 1:1) */
[MY_TEAM]: {type: 'has_many', foreignKey: 'id'},
/** A TEAM has a 1:N relationship with SLASH_COMMAND. A TEAM can possess multiple slash commands */
[SLASH_COMMAND]: {type: 'has_many', foreignKey: 'team_id'},

View file

@ -3,9 +3,12 @@
import {Database, Model, Q, Query, Relation} from '@nozbe/watermelondb';
import {General, Permissions} from '@constants';
import {MM_TABLES} from '@constants/database';
import {hasPermission} from '@utils/role';
import {prepareDeletePost} from './post';
import {queryRoles} from './role';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type ChannelModel from '@typings/database/models/servers/channel';
@ -125,3 +128,34 @@ export const queryChannelsById = async (database: Database, channelIds: string[]
return undefined;
}
};
export const queryDefaultChannelForTeam = async (database: Database, teamId: string) => {
let channel: ChannelModel|undefined;
let canIJoinPublicChannelsInTeam = false;
const roles = await queryRoles(database);
if (roles.length) {
canIJoinPublicChannelsInTeam = hasPermission(roles, Permissions.JOIN_PUBLIC_CHANNELS, true);
}
const myChannels = await database.get<ChannelModel>(CHANNEL).query(
Q.on(MY_CHANNEL, 'id', Q.notEq('')),
Q.and(
Q.where('team_id', teamId),
Q.where('delete_at', 0),
Q.where('type', General.OPEN_CHANNEL),
),
Q.experimentalSortBy('display_name', Q.asc),
).fetch();
const defaultChannel = myChannels.find((c) => c.name === General.DEFAULT_CHANNEL);
const myFirstTeamChannel = myChannels[0];
if (defaultChannel || canIJoinPublicChannelsInTeam) {
channel = defaultChannel;
} else {
channel = myFirstTeamChannel || defaultChannel;
}
return channel;
};

View file

@ -7,7 +7,7 @@ import {Database as DatabaseConstants, Preferences} from '@constants';
import {getPreferenceValue} from '@helpers/api/preference';
import {selectDefaultTeam} from '@helpers/api/team';
import {prepareDeleteChannel} from './channel';
import {prepareDeleteChannel, queryDefaultChannelForTeam} from './channel';
import {queryPreferencesByCategoryAndName} from './preference';
import {queryConfig} from './system';
import {queryCurrentUser} from './user';
@ -46,6 +46,25 @@ export const addChannelToTeamHistory = async (operator: ServerDataOperator, team
return operator.handleTeamChannelHistory({teamChannelHistories: [tch], prepareRecordsOnly});
};
export const queryLastChannelFromTeam = async (database: Database, teamId: string) => {
let channelId = '';
try {
const teamChannelHistory = await database.get<TeamChannelHistoryModel>(TEAM_CHANNEL_HISTORY).find(teamId);
if (teamChannelHistory.channelIds.length) {
channelId = teamChannelHistory.channelIds[0];
}
} catch {
// No channel history for the team
const channel = await queryDefaultChannelForTeam(database, teamId);
if (channel) {
channelId = channel.id;
}
}
return channelId;
};
export const prepareMyTeams = (operator: ServerDataOperator, teams: Team[], memberships: TeamMembership[]) => {
try {
const teamRecords = operator.handleTeam({prepareRecordsOnly: true, teams});

View file

@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {GestureResponderEvent, Text, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
onPress?: (e: GestureResponderEvent) => void;
icon?: string;
text?: string;
}
export default function BottomSheetButton({onPress, icon, text}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<TouchableWithFeedback
onPress={onPress}
type='opacity'
style={styles.button}
>
{icon && (
<View style={styles.icon_container}>
<CompassIcon
size={24}
name={icon}
color={theme.buttonColor}
/>
</View>
)}
{text && (
<Text
style={styles.text}
>{text}</Text>
)}
</TouchableWithFeedback>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
button: {
backgroundColor: theme.buttonBg,
display: 'flex',
flexDirection: 'row',
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
height: 48,
},
text: {
color: theme.buttonColor,
fontWeight: 'bold',
fontSize: 16,
lineHeight: 18,
paddingHorizontal: 1,
},
icon_container: {
width: 24,
height: 24,
marginBottom: 1,
},
};
});

View file

@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {GestureResponderEvent, Platform, Text, useWindowDimensions, View} from 'react-native';
import {Device} from '@constants';
import {useTheme} from '@context/theme';
import {useSplitView} from '@hooks/device';
import Button from '@screens/bottom_sheet/button';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
buttonIcon?: string;
buttonText?: string;
children: React.ReactNode;
onPress?: (e: GestureResponderEvent) => void;
showButton: boolean;
showTitle: boolean;
title?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
},
titleContainer: {
marginTop: 4,
marginBottom: 4,
},
titleText: {
color: theme.centerChannelColor,
lineHeight: 30,
fontSize: 25,
fontFamily: 'OpenSans-Semibold',
},
separator: {
height: 1,
right: 16,
borderTopWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.08),
},
};
});
const BottomSheetContent = ({buttonText, buttonIcon, children, onPress, showButton, showTitle, title}: Props) => {
const dimensions = useWindowDimensions();
const theme = useTheme();
const isSplitView = useSplitView();
const isTablet = Device.IS_TABLET && !isSplitView;
const styles = getStyleSheet(theme);
const separatorWidth = Math.max(dimensions.width, 450);
return (
<View style={styles.container}>
{showTitle &&
<View style={styles.titleContainer}>
<Text style={styles.titleText}>{title}</Text>
</View>
}
<>
{children}
</>
{showButton && (
<>
<View style={[styles.separator, {width: separatorWidth, marginBottom: (isTablet ? 20 : 12)}]}/>
<Button
onPress={onPress}
icon={buttonIcon}
text={buttonText}
/>
<View style={{paddingBottom: Platform.select({ios: (isTablet ? 20 : 32), android: 20})}}/>
</>
)}
</View>
);
};
export default BottomSheetContent;

View file

@ -91,14 +91,13 @@ const BottomSheet = ({closeButtonId, initialSnapIndex = 0, renderContent, snapPo
);
};
const renderContainer = () => (
const renderContainerContent = () => (
<View
style={{
backgroundColor: theme.centerChannelBg,
opacity: 1,
paddingHorizontal: 16,
paddingBottom: isTablet ? 20 : 16,
paddingTop: isTablet ? 0 : 16,
paddingHorizontal: 20,
paddingTop: isTablet ? 0 : 20,
height: '100%',
width: isTablet ? '100%' : Math.min(dimensions.width, 450),
alignSelf: 'center',
@ -113,7 +112,7 @@ const BottomSheet = ({closeButtonId, initialSnapIndex = 0, renderContent, snapPo
return (
<>
<View style={styles.separator}/>
{renderContainer()}
{renderContainerContent()}
</>
);
}
@ -125,7 +124,7 @@ const BottomSheet = ({closeButtonId, initialSnapIndex = 0, renderContent, snapPo
snapPoints={snapPoints}
borderRadius={10}
initialSnap={initialSnapIndex}
renderContent={renderContainer}
renderContent={renderContainerContent}
onCloseEnd={() => dismissModal()}
enabledBottomInitialAnimation={true}
renderHeader={Indicator}

View file

@ -89,7 +89,7 @@ const ChannelNavBar = ({channel, onPress}: ChannelNavBar) => {
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
header: {
backgroundColor: theme.sidebarHeaderBg,
backgroundColor: theme.sidebarBg,
flexDirection: 'row',
justifyContent: 'flex-start',
width: '100%',

View file

@ -5,8 +5,9 @@ import {useIsFocused, useRoute} from '@react-navigation/native';
import React from 'react';
import {Text, View} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {SafeAreaView} from 'react-native-safe-area-context';
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import TeamSidebar from '@components/team_sidebar';
import {Device, View as ViewConstants} from '@constants';
import {useTheme} from '@context/theme';
import {useSplitView} from '@hooks/device';
@ -46,12 +47,13 @@ const ChannelListScreen = (props: ChannelProps) => {
const showTabletLayout = Device.IS_TABLET && !isSplitView;
const route = useRoute();
const isFocused = useIsFocused();
const insets = useSafeAreaInsets();
const params = route.params as {direction: string};
let tabletSidebarStyle;
if (showTabletLayout) {
const {TABLET} = ViewConstants;
tabletSidebarStyle = {maxWidth: TABLET.SIDEBAR_WIDTH};
const {TABLET, TEAM_SIDEBAR_WIDTH} = ViewConstants;
tabletSidebarStyle = {maxWidth: (TABLET.SIDEBAR_WIDTH - TEAM_SIDEBAR_WIDTH)};
}
const animated = useAnimatedStyle(() => {
@ -72,25 +74,30 @@ const ChannelListScreen = (props: ChannelProps) => {
}, [isFocused, params]);
return (
<SafeAreaView
style={styles.flex}
>
<Animated.View
style={[styles.content, animated]}
<>
{Boolean(insets.top) && <View style={{height: insets.top, backgroundColor: theme.sidebarBg}}/>}
<SafeAreaView
style={styles.content}
edges={['bottom', 'left', 'right']}
>
<View style={[styles.flex, {alignItems: 'center', justifyContent: 'center'}, tabletSidebarStyle]}>
<Text
onPress={() => goToScreen('Channel', '', undefined, {topBar: {visible: false}})}
style={{fontSize: 20, color: theme.centerChannelColor}}
>
{'Channel List'}
</Text>
</View>
{showTabletLayout &&
<Channel {...props}/>
}
</Animated.View>
</SafeAreaView>
<Animated.View
style={[styles.content, animated]}
>
<TeamSidebar/>
<View style={[styles.flex, {alignItems: 'center', justifyContent: 'center'}, tabletSidebarStyle]}>
<Text
onPress={() => goToScreen('Channel', '', undefined, {topBar: {visible: false}})}
style={{fontSize: 20, color: theme.centerChannelColor}}
>
{'Channel List'}
</Text>
</View>
{showTabletLayout &&
<Channel {...props}/>
}
</Animated.View>
</SafeAreaView>
</>
);
};

View file

@ -90,18 +90,17 @@ const Home = ({isFocused, theme}: Props) => {
if (server.lastActiveAt) {
const sdb = DatabaseManager.serverDatabases[serverUrl];
if (sdb?.database) {
const subscription = sdb.database.
get(MY_CHANNEL).
query(Q.on(CHANNEL, Q.where('delete_at', Q.eq(0)))).
observeWithColumns(['mentions_count', 'message_count']).
subscribe(unreadsSubscription.bind(undefined, serverUrl));
if (!subscriptions.has(serverUrl)) {
const unreads: UnreadSubscription = {
mentions: 0,
messages: 0,
subscription,
};
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', 'message_count']).
subscribe(unreadsSubscription.bind(undefined, serverUrl));
}
}

View file

@ -2,9 +2,14 @@
// See LICENSE.txt for license information.
import moment, {Moment} from 'moment-timezone';
import {NativeModules} from 'react-native';
import {Device} from '@constants';
import {CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES} from '@constants/custom_status';
const {MattermostManaged} = NativeModules;
const isRunningInSplitView = MattermostManaged.isRunningInSplitView;
// isMinimumServerVersion will return true if currentVersion is equal to higher or than
// the provided minimum version. A non-equal major version will ignore minor and dot
// versions, and a non-equal minor version will ignore dot version.
@ -119,3 +124,12 @@ export function getRoundedTime(value: Moment) {
const remainder = roundedTo - diff;
return start.add(remainder, 'm').seconds(0).milliseconds(0);
}
export async function isTablet() {
if (Device.IS_TABLET) {
const {isSplitView} = await isRunningInSplitView();
return !isSplitView;
}
return false;
}

View file

@ -148,6 +148,8 @@
"mobile.about.serverVersion": "Server Version: {version} (Build {number})",
"mobile.about.serverVersionNoBuild": "Server Version: {version}",
"mobile.action_menu.select": "Select an option",
"mobile.add_team.create_team": "Create a New Team",
"mobile.add_team.join_team": "Join Another Team",
"mobile.components.select_server_view.connect": "Connect",
"mobile.components.select_server_view.connecting": "Connecting...",
"mobile.components.select_server_view.enterServerUrl": "Enter Server URL",
@ -292,5 +294,7 @@
"status_dropdown.set_offline": "Offline",
"status_dropdown.set_online": "Online",
"status_dropdown.set_ooo": "Out Of Office",
"team_list.no_other_teams.description": "To join another team, ask a Team Admin for an invitation, or create your own team.",
"team_list.no_other_teams.title": "No additional teams to join",
"web.root.signup_info": "All team communication in one place, searchable and accessible anywhere"
}

View file

@ -1,13 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
module.exports = {
transformer: {
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
},
};
const {getDefaultConfig} = require('metro-config');
module.exports = (async () => {
const {
resolver: {sourceExts, assetExts},
} = await getDefaultConfig();
return {
transformer: {
babelTransformerPath: require.resolve('react-native-svg-transformer'),
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
},
resolver: {
assetExts: assetExts.filter((ext) => ext !== 'svg'),
sourceExts: [...sourceExts, 'svg'],
},
};
})();

881
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -145,6 +145,7 @@
"react-native-dev-menu": "4.0.2",
"react-native-dotenv": "3.2.0",
"react-native-storybook-loader": "2.0.4",
"react-native-svg-transformer": "0.14.3",
"react-test-renderer": "17.0.2",
"ts-jest": "27.0.5",
"typescript": "4.4.3",