From e6f9b0e2583a86f8fca98f6c45a8577b1761ffb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Fri, 15 Oct 2021 09:55:02 +0200 Subject: [PATCH] Gekidou teamsidebar (#5718) --- app/actions/local/team.ts | 35 + app/actions/remote/team.ts | 38 +- app/components/badge/index.tsx | 2 +- .../team_sidebar/add_team/add_team.tsx | 126 +++ .../add_team/add_team_slide_up.tsx | 41 + .../team_sidebar/add_team/no_teams.svg | 73 ++ .../team_sidebar/add_team/team_list.tsx | 93 ++ .../add_team/team_list_item/index.ts | 23 + .../team_list_item/team_list_item.tsx | 80 ++ app/components/team_sidebar/index.ts | 53 ++ .../team_sidebar/server_icon/server_icon.tsx | 38 + .../team_sidebar/team_list/index.ts | 68 ++ .../team_sidebar/team_list/team_item/index.ts | 51 + .../team_list/team_item/team_icon.tsx | 102 ++ .../team_list/team_item/team_item.tsx | 113 +++ .../team_sidebar/team_list/team_list.tsx | 48 + app/components/team_sidebar/team_sidebar.tsx | 65 ++ app/constants/view.ts | 2 + app/database/models/server/channel.ts | 4 +- app/database/models/server/my_team.ts | 6 +- app/database/models/server/team.ts | 3 + app/queries/servers/channel.ts | 34 + app/queries/servers/team.ts | 21 +- app/screens/bottom_sheet/button.tsx | 72 ++ app/screens/bottom_sheet/content.tsx | 80 ++ app/screens/bottom_sheet/index.tsx | 11 +- app/screens/channel/channel_nav_bar/index.tsx | 2 +- app/screens/home/channel_list/index.tsx | 49 +- app/screens/home/tab_bar/home.tsx | 11 +- app/utils/helpers.ts | 14 + assets/base/i18n/en.json | 4 + metro.config.js | 32 +- package-lock.json | 881 +++++++++++++++++- package.json | 1 + 34 files changed, 2214 insertions(+), 62 deletions(-) create mode 100644 app/actions/local/team.ts create mode 100644 app/components/team_sidebar/add_team/add_team.tsx create mode 100644 app/components/team_sidebar/add_team/add_team_slide_up.tsx create mode 100644 app/components/team_sidebar/add_team/no_teams.svg create mode 100644 app/components/team_sidebar/add_team/team_list.tsx create mode 100644 app/components/team_sidebar/add_team/team_list_item/index.ts create mode 100644 app/components/team_sidebar/add_team/team_list_item/team_list_item.tsx create mode 100644 app/components/team_sidebar/index.ts create mode 100644 app/components/team_sidebar/server_icon/server_icon.tsx create mode 100644 app/components/team_sidebar/team_list/index.ts create mode 100644 app/components/team_sidebar/team_list/team_item/index.ts create mode 100644 app/components/team_sidebar/team_list/team_item/team_icon.tsx create mode 100644 app/components/team_sidebar/team_list/team_item/team_item.tsx create mode 100644 app/components/team_sidebar/team_list/team_list.tsx create mode 100644 app/components/team_sidebar/team_sidebar.tsx create mode 100644 app/screens/bottom_sheet/button.tsx create mode 100644 app/screens/bottom_sheet/content.tsx diff --git a/app/actions/local/team.ts b/app/actions/local/team.ts new file mode 100644 index 000000000..2a1090f57 --- /dev/null +++ b/app/actions/local/team.ts @@ -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); +}; diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 16bfca9bd..f10cce512 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -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 => { + 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) { diff --git a/app/components/badge/index.tsx b/app/components/badge/index.tsx index 813b1aeaf..720530705 100644 --- a/app/components/badge/index.tsx +++ b/app/components/badge/index.tsx @@ -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) { diff --git a/app/components/team_sidebar/add_team/add_team.tsx b/app/components/team_sidebar/add_team/add_team.tsx new file mode 100644 index 000000000..297a3074f --- /dev/null +++ b/app/components/team_sidebar/add_team/add_team.tsx @@ -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 ( + + ); + }; + + 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 ( + + + + + + ); +} + +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', + }, + }; +}); diff --git a/app/components/team_sidebar/add_team/add_team_slide_up.tsx b/app/components/team_sidebar/add_team/add_team_slide_up.tsx new file mode 100644 index 000000000..5f320d561 --- /dev/null +++ b/app/components/team_sidebar/add_team/add_team_slide_up.tsx @@ -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 ( + + + + ); +} diff --git a/app/components/team_sidebar/add_team/no_teams.svg b/app/components/team_sidebar/add_team/no_teams.svg new file mode 100644 index 000000000..30a0bec19 --- /dev/null +++ b/app/components/team_sidebar/add_team/no_teams.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/components/team_sidebar/add_team/team_list.tsx b/app/components/team_sidebar/add_team/team_list.tsx new file mode 100644 index 000000000..583338c24 --- /dev/null +++ b/app/components/team_sidebar/add_team/team_list.tsx @@ -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) => { + return ( + + ); +}; + +const keyExtractor = (item: TeamModel) => item.id; + +export default function TeamList({teams}: Props) { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + if (teams.length) { + return ( + + + + ); + } + + return ( + + + + + + ); +} diff --git a/app/components/team_sidebar/add_team/team_list_item/index.ts b/app/components/team_sidebar/add_team/team_list_item/index.ts new file mode 100644 index 000000000..e091c844c --- /dev/null +++ b/app/components/team_sidebar/add_team/team_list_item/index.ts @@ -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(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap((currentUserId) => of$(currentUserId.value))), +})); + +export default withDatabase(withSystem(TeamListItem)); diff --git a/app/components/team_sidebar/add_team/team_list_item/team_list_item.tsx b/app/components/team_sidebar/add_team/team_list_item/team_list_item.tsx new file mode 100644 index 000000000..8d6d3c972 --- /dev/null +++ b/app/components/team_sidebar/add_team/team_list_item/team_list_item.tsx @@ -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 ( + + + + + + {team.displayName} + + + ); +} + +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, + }, + }; +}); diff --git a/app/components/team_sidebar/index.ts b/app/components/team_sidebar/index.ts new file mode 100644 index 000000000..da295fa97 --- /dev/null +++ b/app/components/team_sidebar/index.ts @@ -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(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(MY_TEAM).query().observe().pipe( + switchMap((mm) => { + // eslint-disable-next-line max-nested-callbacks + const ids = mm.map((m) => m.id); + return database.get(TEAM).query(Q.where('id', Q.notIn(ids))).observe(); + }), + ); + + return { + canCreateTeams, + otherTeams, + }; +}); + +export default withDatabase(withSystem(withTeams(TeamSidebar))); diff --git a/app/components/team_sidebar/server_icon/server_icon.tsx b/app/components/team_sidebar/server_icon/server_icon.tsx new file mode 100644 index 000000000..013dd7c8e --- /dev/null +++ b/app/components/team_sidebar/server_icon/server_icon.tsx @@ -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 ( + + + + ); +} + +const getStyleSheet = makeStyleSheetFromTheme(() => { + return { + container: { + flex: 0, + borderRadius: 10, + height: 60, + width: '100%', + overflow: 'hidden', + alignItems: 'center', + justifyContent: 'center', + }, + }; +}); diff --git a/app/components/team_sidebar/team_list/index.ts b/app/components/team_sidebar/team_list/index.ts new file mode 100644 index 000000000..e4797ccc7 --- /dev/null +++ b/app/components/team_sidebar/team_list/index.ts @@ -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(MY_TEAM).query().observe(); + const teamIds = database.get(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(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)); diff --git a/app/components/team_sidebar/team_list/team_item/index.ts b/app/components/team_sidebar/team_list/team_item/index.ts new file mode 100644 index 000000000..0809dfa23 --- /dev/null +++ b/app/components/team_sidebar/team_list/team_item/index.ts @@ -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(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(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))); diff --git a/app/components/team_sidebar/team_list/team_item/team_icon.tsx b/app/components/team_sidebar/team_list/team_item/team_icon.tsx new file mode 100644 index 000000000..21b414536 --- /dev/null +++ b/app/components/team_sidebar/team_list/team_item/team_icon.tsx @@ -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(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 = ( + + {displayName?.substr(0, 2).toUpperCase()} + + ); + } else { + teamIconContent = ( + + ); + } + + return ( + + {teamIconContent} + + ); +} + +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', + }, + }; +}); diff --git a/app/components/team_sidebar/team_list/team_item/team_item.tsx b/app/components/team_sidebar/team_list/team_item/team_item.tsx new file mode 100644 index 000000000..6fa0f1b45 --- /dev/null +++ b/app/components/team_sidebar/team_list/team_item/team_item.tsx @@ -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 ( + <> + + handleTeamChange(serverUrl, team.id)} + type='opacity' + > + + + + 0 ? [styles.mentions, {left}] : styles.unread} + size={mentionCount > 0 ? 16 : 12} + > + {mentionText} + + + ); +} + +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, + }, + }; +}); diff --git a/app/components/team_sidebar/team_list/team_list.tsx b/app/components/team_sidebar/team_list/team_list.tsx new file mode 100644 index 000000000..1f21fdad5 --- /dev/null +++ b/app/components/team_sidebar/team_list/team_list.tsx @@ -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) => { + return ( + + ); +}; + +export default function TeamList({myOrderedTeams}: Props) { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexShrink: 1, + }, + contentContainer: { + alignItems: 'center', + marginVertical: 6, + }, +}); diff --git a/app/components/team_sidebar/team_sidebar.tsx b/app/components/team_sidebar/team_sidebar.tsx new file mode 100644 index 000000000..8d801dbf4 --- /dev/null +++ b/app/components/team_sidebar/team_sidebar.tsx @@ -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 ( + + + + + {showAddTeam && ( + + )} + + + ); +} + +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, + }, + }; +}); diff --git a/app/constants/view.ts b/app/constants/view.ts index 490cf9a20..2e7b0ffa8 100644 --- a/app/constants/view.ts +++ b/app/constants/view.ts @@ -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, }; diff --git a/app/database/models/server/channel.ts b/app/database/models/server/channel.ts index a81de37e6..529a1c571 100644 --- a/app/database/models/server/channel.ts +++ b/app/database/models/server/channel.ts @@ -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'}, diff --git a/app/database/models/server/my_team.ts b/app/database/models/server/my_team.ts index 4e133da76..b9f1ffabe 100644 --- a/app/database/models/server/my_team.ts +++ b/app/database/models/server/my_team.ts @@ -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; diff --git a/app/database/models/server/team.ts b/app/database/models/server/team.ts index 65e3efa70..3ce934a3f 100644 --- a/app/database/models/server/team.ts +++ b/app/database/models/server/team.ts @@ -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'}, diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 5945428f1..540558211 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -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(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; +}; diff --git a/app/queries/servers/team.ts b/app/queries/servers/team.ts index 97caf4e32..462df90ac 100644 --- a/app/queries/servers/team.ts +++ b/app/queries/servers/team.ts @@ -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(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}); diff --git a/app/screens/bottom_sheet/button.tsx b/app/screens/bottom_sheet/button.tsx new file mode 100644 index 000000000..fe7efe1eb --- /dev/null +++ b/app/screens/bottom_sheet/button.tsx @@ -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 ( + + {icon && ( + + + + )} + {text && ( + {text} + )} + + + ); +} + +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, + }, + }; +}); diff --git a/app/screens/bottom_sheet/content.tsx b/app/screens/bottom_sheet/content.tsx new file mode 100644 index 000000000..d56747dbb --- /dev/null +++ b/app/screens/bottom_sheet/content.tsx @@ -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 ( + + {showTitle && + + {title} + + } + <> + {children} + + {showButton && ( + <> + +