diff --git a/app/actions/remote/entry/app.ts b/app/actions/remote/entry/app.ts index 5804a72dbd..f5caae5536 100644 --- a/app/actions/remote/entry/app.ts +++ b/app/actions/remote/entry/app.ts @@ -46,7 +46,7 @@ export async function appEntry(serverUrl: string, since = 0) { } const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds} = fetchedData as AppEntryData; - const rolesData = await fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user, true); + const rolesData = await fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user, true, true); if (initialTeamId === currentTeamId) { if (tabletDevice) { diff --git a/app/actions/remote/role.ts b/app/actions/remote/role.ts index 63d8bdf369..2856fdf11b 100644 --- a/app/actions/remote/role.ts +++ b/app/actions/remote/role.ts @@ -12,7 +12,7 @@ export type RolesRequest = { roles?: Role[]; } -export const fetchRolesIfNeeded = async (serverUrl: string, updatedRoles: string[], fetchOnly = false): Promise => { +export const fetchRolesIfNeeded = async (serverUrl: string, updatedRoles: string[], fetchOnly = false, force = false): Promise => { if (!updatedRoles.length) { return {roles: []}; } @@ -26,15 +26,20 @@ export const fetchRolesIfNeeded = async (serverUrl: string, updatedRoles: string const database = DatabaseManager.serverDatabases[serverUrl].database; const operator = DatabaseManager.serverDatabases[serverUrl].operator; - const existingRoles = await queryRoles(database).fetch(); + let newRoles; + if (force) { + newRoles = updatedRoles; + } else { + const existingRoles = await queryRoles(database).fetch(); - const roleNames = new Set(existingRoles.map((role) => { - return role.name; - })); + const roleNames = new Set(existingRoles.map((role) => { + return role.name; + })); - const newRoles = updatedRoles.filter((newRole) => { - return !roleNames.has(newRole); - }); + newRoles = updatedRoles.filter((newRole) => { + return !roleNames.has(newRole); + }); + } if (!newRoles.length) { return {roles: []}; @@ -56,7 +61,7 @@ export const fetchRolesIfNeeded = async (serverUrl: string, updatedRoles: string } }; -export const fetchRoles = async (serverUrl: string, teamMembership?: TeamMembership[], channelMembership?: ChannelMembership[], user?: UserProfile, fetchOnly = false) => { +export const fetchRoles = async (serverUrl: string, teamMembership?: TeamMembership[], channelMembership?: ChannelMembership[], user?: UserProfile, fetchOnly = false, force = false) => { const rolesToFetch = new Set(user?.roles.split(' ') || []); if (teamMembership?.length) { @@ -78,7 +83,7 @@ export const fetchRoles = async (serverUrl: string, teamMembership?: TeamMembers rolesToFetch.delete(''); if (rolesToFetch.size > 0) { - return fetchRolesIfNeeded(serverUrl, Array.from(rolesToFetch), fetchOnly); + return fetchRolesIfNeeded(serverUrl, Array.from(rolesToFetch), fetchOnly, force); } return {roles: []}; diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 93bdde7cd0..115303f6d2 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -13,6 +13,7 @@ import {prepareCategories, prepareCategoryChannels} from '@queries/servers/categ import {prepareMyChannelsForTeam, getDefaultChannelForTeam} from '@queries/servers/channel'; import {prepareCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected} from '@queries/servers/system'; import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, getNthLastChannelFromTeam, queryTeamsById, syncTeamTable} from '@queries/servers/team'; +import EphemeralStore from '@store/ephemeral_store'; import {isTablet} from '@utils/helpers'; import {fetchMyChannelsForTeam, switchToChannelById} from './channel'; @@ -37,6 +38,7 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s } try { + EphemeralStore.startAddingToTeam(teamId); const member = await client.addToTeam(teamId, userId); if (!fetchOnly) { @@ -67,9 +69,10 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s } } } - + EphemeralStore.finishAddingToTeam(teamId); return {member}; } catch (error) { + EphemeralStore.finishAddingToTeam(teamId); forceLogoutIfNecessary(serverUrl, error as ClientError); return {error}; } diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index 9dabeaca48..61ed91f249 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -251,6 +251,9 @@ export async function handleUserAddedToChannelEvent(serverUrl: string, msg: any) try { if (userId === currentUser?.id) { + if (EphemeralStore.isAddingToTeam(teamId)) { + return; + } const {channels, memberships} = await fetchMyChannel(serverUrl, teamId, channelId, true); if (channels && memberships) { const prepare = await prepareMyChannelsForTeam(operator, teamId, channels, memberships); diff --git a/app/actions/websocket/teams.ts b/app/actions/websocket/teams.ts index 117ec94186..ee96db7868 100644 --- a/app/actions/websocket/teams.ts +++ b/app/actions/websocket/teams.ts @@ -5,16 +5,19 @@ import {Model} from '@nozbe/watermelondb'; import {DeviceEventEmitter} from 'react-native'; import {removeUserFromTeam} from '@actions/local/team'; -import {fetchRolesIfNeeded} from '@actions/remote/role'; +import {fetchMyChannelsForTeam} from '@actions/remote/channel'; +import {fetchRoles} from '@actions/remote/role'; import {fetchAllTeams, handleTeamChange, fetchMyTeam} from '@actions/remote/team'; import {updateUsersNoLongerVisible} from '@actions/remote/user'; import Events from '@constants/events'; import DatabaseManager from '@database/manager'; import {getActiveServerUrl} from '@queries/app/servers'; -import {getCurrentTeamId} from '@queries/servers/system'; -import {getLastTeam, prepareMyTeams} from '@queries/servers/team'; +import {prepareCategories, prepareCategoryChannels} from '@queries/servers/categories'; +import {prepareMyChannelsForTeam} from '@queries/servers/channel'; +import {getCurrentTeam, getLastTeam, prepareMyTeams} from '@queries/servers/team'; import {getCurrentUser} from '@queries/servers/user'; -import {dismissAllModals, popToRoot} from '@screens/navigation'; +import {dismissAllModals, popToRoot, resetToTeams} from '@screens/navigation'; +import EphemeralStore from '@store/ephemeral_store'; export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMessage) { const database = DatabaseManager.serverDatabases[serverUrl]; @@ -22,7 +25,7 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess return; } - const currentTeamId = await getCurrentTeamId(database.database); + const currentTeam = await getCurrentTeam(database.database); const user = await getCurrentUser(database.database); if (!user) { return; @@ -37,7 +40,7 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess updateUsersNoLongerVisible(serverUrl); } - if (currentTeamId === teamId) { + if (currentTeam?.id === teamId) { const appDatabase = DatabaseManager.appDatabase?.database; let currentServer = ''; if (appDatabase) { @@ -45,7 +48,7 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess } if (currentServer === serverUrl) { - DeviceEventEmitter.emit(Events.LEAVE_TEAM); + DeviceEventEmitter.emit(Events.LEAVE_TEAM, currentTeam?.displayName); await dismissAllModals(); await popToRoot(); } @@ -53,7 +56,9 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess const teamToJumpTo = await getLastTeam(database.database); if (teamToJumpTo) { handleTeamChange(serverUrl, teamToJumpTo); - } // TODO else jump to "join a team" screen + } else if (currentServer === serverUrl) { + resetToTeams(); + } } } } @@ -70,57 +75,43 @@ export async function handleUpdateTeamEvent(serverUrl: string, msg: WebSocketMes teams: [team], prepareRecordsOnly: false, }); - } catch { + } catch (err) { // Do nothing } } -// As of today, the server sends a duplicated event to add the user to the team. -// If we do not handle this, this ends up showing some errors in the database, apart -// of the extra computation time. We use this to track the events that are being handled -// and make sure we only handle one. -const addingTeam: {[id: string]: boolean} = {}; - export async function handleUserAddedToTeamEvent(serverUrl: string, msg: WebSocketMessage) { - const database = DatabaseManager.serverDatabases[serverUrl]; - if (!database) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { return; } const {team_id: teamId} = msg.data; // Ignore duplicated team join events sent by the server - if (addingTeam[teamId]) { + if (EphemeralStore.isAddingToTeam(teamId)) { return; } - addingTeam[teamId] = true; + EphemeralStore.startAddingToTeam(teamId); const {teams, memberships: teamMemberships} = await fetchMyTeam(serverUrl, teamId, true); const modelPromises: Array> = []; if (teams?.length && teamMemberships?.length) { - const myMember = teamMemberships[0]; - if (myMember.roles) { - const rolesToLoad = new Set(); - for (const role of myMember.roles.split(' ')) { - rolesToLoad.add(role); - } - const serverRoles = await fetchRolesIfNeeded(serverUrl, Array.from(rolesToLoad), true); - if (serverRoles.roles?.length) { - const preparedRoleModels = database.operator.handleRole({ - roles: serverRoles.roles, - prepareRecordsOnly: true, - }); - modelPromises.push(preparedRoleModels); - } - } + const {channels, memberships, categories} = await fetchMyChannelsForTeam(serverUrl, teamId, false, 0, true); + modelPromises.push(prepareCategories(operator, categories)); + modelPromises.push(prepareCategoryChannels(operator, categories)); + modelPromises.push(...await prepareMyChannelsForTeam(operator, teamId, channels || [], memberships || [])); + + const {roles} = await fetchRoles(serverUrl, teamMemberships, memberships, undefined, true); + modelPromises.push(operator.handleRole({roles, prepareRecordsOnly: true})); } if (teams && teamMemberships) { - modelPromises.push(...prepareMyTeams(database.operator, teams, teamMemberships)); + modelPromises.push(...prepareMyTeams(operator, teams, teamMemberships)); } const models = await Promise.all(modelPromises); - await database.operator.batchRecords(models.flat()); + await operator.batchRecords(models.flat()); - delete addingTeam[teamId]; + EphemeralStore.finishAddingToTeam(teamId); } diff --git a/app/components/illustrations/no_team.tsx b/app/components/illustrations/no_team.tsx new file mode 100644 index 0000000000..e2829bdf53 --- /dev/null +++ b/app/components/illustrations/no_team.tsx @@ -0,0 +1,270 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import * as React from 'react'; +import Svg, { + Path, + Mask, + G, + Ellipse, + Defs, + Pattern, + Use, + Image, + EMaskUnits, +} from 'react-native-svg'; + +type Props = { + theme: Theme; +} + +function SvgComponent({theme}: Props) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default SvgComponent; diff --git a/app/components/loading_error/__snapshots__/index.test.tsx.snap b/app/components/loading_error/__snapshots__/index.test.tsx.snap index f9fb459eed..6fc1308906 100644 --- a/app/components/loading_error/__snapshots__/index.test.tsx.snap +++ b/app/components/loading_error/__snapshots__/index.test.tsx.snap @@ -73,78 +73,55 @@ exports[`Loading Error should match snapshot 1`] = ` Error description - - - Retry - - + Retry + `; diff --git a/app/components/loading_error/index.tsx b/app/components/loading_error/index.tsx index ab46c0312a..d5b6131789 100644 --- a/app/components/loading_error/index.tsx +++ b/app/components/loading_error/index.tsx @@ -84,6 +84,7 @@ const LoadingError = ({loading, message, onRetry, title}: Props) => { {'Retry'} 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 index c46223b931..4672a99f9c 100644 --- a/app/components/team_sidebar/add_team/add_team_slide_up.tsx +++ b/app/components/team_sidebar/add_team/add_team_slide_up.tsx @@ -21,14 +21,18 @@ export default function AddTeamSlideUp({otherTeams, canCreateTeams, showTitle = const intl = useIntl(); const onPressCreate = useCallback(() => { - //TODO Create team screen + //TODO Create team screen https://mattermost.atlassian.net/browse/MM-43622 + dismissBottomSheet(); + }, []); + + const onTeamAdded = useCallback(() => { dismissBottomSheet(); }, []); return ( diff --git a/app/components/team_sidebar/add_team/no_teams.svg b/app/components/team_sidebar/add_team/no_teams.svg deleted file mode 100644 index 30a0bec193..0000000000 --- a/app/components/team_sidebar/add_team/no_teams.svg +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ 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 index 5521d7ea2f..a164c1ff7e 100644 --- a/app/components/team_sidebar/add_team/team_list.tsx +++ b/app/components/team_sidebar/add_team/team_list.tsx @@ -1,11 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useCallback} from 'react'; 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 Empty from '@components/illustrations/no_team'; import {useTheme} from '@context/theme'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -13,10 +14,12 @@ 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[]; + teams: Array; + textColor?: string; + iconTextColor?: string; + iconBackgroundColor?: string; + onTeamAdded: (id: string) => void; testID?: string; } @@ -49,20 +52,24 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const renderTeam = ({item: t}: ListRenderItemInfo) => { - return ( - - ); -}; - const keyExtractor = (item: TeamModel) => item.id; -export default function TeamList({teams, testID}: Props) { +export default function TeamList({teams, textColor, iconTextColor, iconBackgroundColor, onTeamAdded, testID}: Props) { const theme = useTheme(); const styles = getStyleSheet(theme); + const renderTeam = useCallback(({item: t}: ListRenderItemInfo) => { + return ( + + ); + }, [textColor, iconTextColor, iconBackgroundColor, onTeamAdded]); + if (teams.length) { return ( @@ -79,7 +86,7 @@ export default function TeamList({teams, testID}: Props) { return ( - + void; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -46,15 +49,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -export default function TeamListItem({team, currentUserId}: Props) { +export default function TeamListItem({team, currentUserId, textColor, iconTextColor, iconBackgroundColor, onTeamAdded}: Props) { const theme = useTheme(); const styles = getStyleSheet(theme); const serverUrl = useServerUrl(); const onPress = useCallback(async () => { await addUserToTeam(serverUrl, team.id, currentUserId); - dismissBottomSheet(); - }, []); + onTeamAdded(team.id); + }, [onTeamAdded]); + const displayName = 'displayName' in team ? team.displayName : team.display_name; + const lastTeamIconUpdateAt = 'lastTeamIconUpdatedAt' in team ? team.lastTeamIconUpdatedAt : team.last_team_icon_update; const teamListItemTestId = `team_sidebar.team_list.team_list_item.${team.id}`; return ( @@ -67,16 +72,20 @@ export default function TeamListItem({team, currentUserId}: Props) { {team.displayName} + > + {displayName} + ); 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 index 39d199f008..5cc072a46e 100644 --- a/app/components/team_sidebar/team_list/team_item/team_icon.tsx +++ b/app/components/team_sidebar/team_list/team_item/team_icon.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View, Text} from 'react-native'; import FastImage from 'react-native-fast-image'; @@ -10,64 +10,6 @@ import {useTheme} from '@context/theme'; import NetworkManager from '@managers/network_manager'; import {makeStyleSheetFromTheme} from '@utils/theme'; -type Props = { - id: string; - lastIconUpdate: number; - displayName: string; - selected: boolean; - testID?: string; -} - -export default function TeamIcon({id, lastIconUpdate, displayName, selected, testID}: 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?.substring(0, 2).toUpperCase()} - - ); - } else { - teamIconContent = ( - - ); - } - - return ( - - {teamIconContent} - - ); -} - const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { container: { @@ -103,3 +45,85 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, }; }); + +type Props = { + id: string; + lastIconUpdate: number; + displayName: string; + selected: boolean; + backgroundColor?: string; + textColor?: string; + testID?: string; +} + +export default function TeamIcon({ + id, + lastIconUpdate, + displayName, + selected, + textColor, + backgroundColor, + testID, +}: Props) { + const [imageError, setImageError] = useState(false); + const ref = useRef(null); + const theme = useTheme(); + const styles = getStyleSheet(theme); + + const serverUrl = useServerUrl(); + let client = null; + try { + client = NetworkManager.getClient(serverUrl); + } catch (err) { + // Do nothing + } + + useEffect(() => + setImageError(false) + , [id, lastIconUpdate]); + + const handleImageError = useCallback(() => { + if (ref.current) { + setImageError(true); + } + }, []); + + const containerStyle = useMemo(() => { + if (selected) { + return backgroundColor ? [styles.containerSelected, {backgroundColor}] : styles.containerSelected; + } + + return backgroundColor ? [styles.container, {backgroundColor}] : styles.container; + }, [styles, backgroundColor, selected]); + + let teamIconContent; + if (imageError || !lastIconUpdate || !client) { + teamIconContent = ( + + {displayName?.substring(0, 2).toUpperCase()} + + ); + } else { + teamIconContent = ( + + ); + } + + return ( + + {teamIconContent} + + ); +} + diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 66916aa747..6f6ef80676 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -14,6 +14,7 @@ export const CHANNEL_EDIT = 'ChannelEdit'; export const CODE = 'Code'; export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage'; export const CREATE_OR_EDIT_CHANNEL = 'CreateOrEditChannel'; +export const CREATE_TEAM = 'CreateTeam'; export const CUSTOM_STATUS = 'CustomStatus'; export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter'; export const EDIT_POST = 'EditPost'; @@ -31,6 +32,7 @@ export const LATEX = 'Latex'; export const LOGIN = 'Login'; export const MENTIONS = 'Mentions'; export const MFA = 'MFA'; +export const SELECT_TEAM = 'SelectTeam'; export const PARTICIPANTS_LIST = 'ParticipantsList'; export const PERMALINK = 'Permalink'; export const POST_OPTIONS = 'PostOptions'; @@ -59,6 +61,7 @@ export default { CHANNEL_DETAILS, CODE, CREATE_DIRECT_MESSAGE, + CREATE_TEAM, CUSTOM_STATUS_CLEAR_AFTER, CUSTOM_STATUS, EDIT_POST, @@ -76,6 +79,7 @@ export default { LOGIN, MENTIONS, MFA, + SELECT_TEAM, PARTICIPANTS_LIST, PERMALINK, POST_OPTIONS, diff --git a/app/init/launch.ts b/app/init/launch.ts index c2af77a970..5cbcce13c2 100644 --- a/app/init/launch.ts +++ b/app/init/launch.ts @@ -11,7 +11,8 @@ import DatabaseManager from '@database/manager'; import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials'; import {getThemeForCurrentTeam} from '@queries/servers/preference'; import {getCurrentUserId} from '@queries/servers/system'; -import {goToScreen, resetToHome, resetToSelectServer} from '@screens/navigation'; +import {queryMyTeams} from '@queries/servers/team'; +import {goToScreen, resetToHome, resetToSelectServer, resetToTeams} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkType, DeepLinkWithData, LaunchProps, LaunchType} from '@typings/launch'; import {convertToNotificationData} from '@utils/notification'; @@ -112,6 +113,7 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => { } launchToHome({...props, launchType, serverUrl}); + return; } } @@ -142,9 +144,23 @@ const launchToHome = async (props: LaunchProps) => { break; } - // eslint-disable-next-line no-console - console.log('Launch app in Home screen'); - resetToHome(props); + let nTeams = 0; + if (props.serverUrl) { + const database = DatabaseManager.serverDatabases[props.serverUrl]?.database; + if (database) { + nTeams = await queryMyTeams(database).fetchCount(); + } + } + + if (nTeams) { + // eslint-disable-next-line no-console + console.log('Launch app in Home screen'); + resetToHome(props); + } else { + // eslint-disable-next-line no-console + console.log('Launch app in Select Teams screen'); + resetToTeams(); + } }; const launchToServer = (props: LaunchProps, resetNavigation: Boolean) => { diff --git a/app/queries/servers/categories.ts b/app/queries/servers/categories.ts index be3f974d17..8b16f6fdc6 100644 --- a/app/queries/servers/categories.ts +++ b/app/queries/servers/categories.ts @@ -29,18 +29,18 @@ export const queryCategoriesByTeamIds = (database: Database, teamIds: string[]) return database.get(CATEGORY).query(Q.where('team_id', Q.oneOf(teamIds))); }; -export const prepareCategories = (operator: ServerDataOperator, categories: CategoryWithChannels[]) => { +export const prepareCategories = (operator: ServerDataOperator, categories?: CategoryWithChannels[]) => { return operator.handleCategories({categories, prepareRecordsOnly: true}); }; export async function prepareCategoryChannels( operator: ServerDataOperator, - categories: CategoryWithChannels[], + categories?: CategoryWithChannels[], ): Promise { try { const categoryChannels: CategoryChannel[] = []; - categories.forEach((category) => { + categories?.forEach((category) => { category.channel_ids.forEach((channelId, index) => { categoryChannels.push({ id: makeCategoryChannelId(category.team_id, channelId), diff --git a/app/queries/servers/role.ts b/app/queries/servers/role.ts index dbc90a15e5..d57b631cbc 100644 --- a/app/queries/servers/role.ts +++ b/app/queries/servers/role.ts @@ -9,6 +9,9 @@ import {Database as DatabaseConstants, General, Permissions} from '@constants'; import {isDMorGM} from '@utils/channel'; import {hasPermission} from '@utils/role'; +import {observeChannel, observeMyChannel} from './channel'; +import {observeMyTeam} from './team'; + import type ChannelModel from '@typings/database/models/servers/channel'; import type PostModel from '@typings/database/models/servers/post'; import type RoleModel from '@typings/database/models/servers/role'; @@ -35,8 +38,9 @@ export const queryRolesByNames = (database: Database, names: string[]) => { }; export function observePermissionForChannel(channel: ChannelModel, user: UserModel, permission: string, defaultValue: boolean) { - const myChannel = channel.membership.observe(); - const myTeam = channel.teamId ? channel.team.observe().pipe(switchMap((t) => (t ? t.myTeam.observe() : of$(undefined)))) : of$(undefined); + const database = channel.database; + const myChannel = observeMyChannel(database, channel.id); + const myTeam = channel.teamId ? observeMyTeam(database, channel.teamId) : of$(undefined); return combineLatest([myChannel, myTeam]).pipe(switchMap(([mc, mt]) => { const rolesArray = [...user.roles.split(' ')]; @@ -46,32 +50,35 @@ export function observePermissionForChannel(channel: ChannelModel, user: UserMod if (mt) { rolesArray.push(...mt.roles.split(' ')); } - return queryRolesByNames(user.database, rolesArray).observe().pipe( + return queryRolesByNames(database, rolesArray).observeWithColumns(['permissions']).pipe( switchMap((r) => of$(hasPermission(r, permission, defaultValue))), ); })); } export function observePermissionForTeam(team: TeamModel, user: UserModel, permission: string, defaultValue: boolean) { - return team.myTeam.observe().pipe(switchMap((myTeam) => { - const rolesArray = [...user.roles.split(' ')]; + const database = team.database; + return observeMyTeam(database, team.id).pipe( + switchMap((myTeam) => { + const rolesArray = [...user.roles.split(' ')]; - if (myTeam) { - rolesArray.push(...myTeam.roles.split(' ')); - } + if (myTeam) { + rolesArray.push(...myTeam.roles.split(' ')); + } - return queryRolesByNames(user.database, rolesArray).observe().pipe( - switchMap((roles) => of$(hasPermission(roles, permission, defaultValue))), - ); - })); + return queryRolesByNames(database, rolesArray).observeWithColumns(['permissions']).pipe( + switchMap((roles) => of$(hasPermission(roles, permission, defaultValue))), + ); + }), + ); } export function observePermissionForPost(post: PostModel, user: UserModel, permission: string, defaultValue: boolean) { - return post.channel.observe().pipe(switchMap((c) => (c ? observePermissionForChannel(c, user, permission, defaultValue) : of$(defaultValue)))); + return observeChannel(post.database, post.channelId).pipe(switchMap((c) => (c ? observePermissionForChannel(c, user, permission, defaultValue) : of$(defaultValue)))); } export function observeCanManageChannelMembers(post: PostModel, user: UserModel) { - return post.channel.observe().pipe((switchMap((c) => { + return observeChannel(post.database, post.channelId).pipe((switchMap((c) => { if (!c || c.deleteAt !== 0 || isDMorGM(c) || c.name === General.DEFAULT_CHANNEL) { return of$(false); } diff --git a/app/queries/servers/team.ts b/app/queries/servers/team.ts index d45adc93be..f082a71da1 100644 --- a/app/queries/servers/team.ts +++ b/app/queries/servers/team.ts @@ -13,7 +13,7 @@ import {DEFAULT_LOCALE} from '@i18n'; import {prepareDeleteCategory} from './categories'; import {prepareDeleteChannel, getDefaultChannelForTeam, observeMyChannelMentionCount} from './channel'; import {queryPreferencesByCategoryAndName} from './preference'; -import {patchTeamHistory, getConfig, getTeamHistory, observeCurrentTeamId} from './system'; +import {patchTeamHistory, getConfig, getTeamHistory, observeCurrentTeamId, getCurrentTeamId} from './system'; import {observeThreadMentionCount} from './thread'; import {getCurrentUser} from './user'; @@ -29,6 +29,15 @@ const { TEAM_CHANNEL_HISTORY, } = DatabaseConstants.MM_TABLES.SERVER; +export const getCurrentTeam = async (database: Database) => { + const currentTeamId = await getCurrentTeamId(database); + if (currentTeamId) { + return getTeamById(database, currentTeamId); + } + + return undefined; +}; + // Saves channels to team history & excludes & GLOBAL_THREADS from it export const addChannelToTeamHistory = async (operator: ServerDataOperator, teamId: string, channelId: string, prepareRecordsOnly = false) => { let tch: TeamChannelHistory|undefined; diff --git a/app/screens/browse_channels/index.ts b/app/screens/browse_channels/index.ts index a3006cd2f5..070ac02019 100644 --- a/app/screens/browse_channels/index.ts +++ b/app/screens/browse_channels/index.ts @@ -36,7 +36,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { const roles = currentUserId.pipe( switchMap((id) => observeUser(database, id)), switchMap((u) => (u ? of$(u.roles.split(' ')) : of$([]))), - switchMap((values) => queryRolesByNames(database, values).observe()), + switchMap((values) => queryRolesByNames(database, values).observeWithColumns(['permissions'])), ); const canCreateChannels = roles.pipe(switchMap((r) => of$(hasPermission(r, Permissions.CREATE_PUBLIC_CHANNEL, false)))); diff --git a/app/screens/channel/channel_post_list/intro/index.ts b/app/screens/channel/channel_post_list/intro/index.ts index 1a81754c00..c341e6f374 100644 --- a/app/screens/channel/channel_post_list/intro/index.ts +++ b/app/screens/channel/channel_post_list/intro/index.ts @@ -30,7 +30,7 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: {channel if (memberRoles) { combinedRoles.push(...memberRoles); } - return queryRolesByNames(database, combinedRoles).observe(); + return queryRolesByNames(database, combinedRoles).observeWithColumns(['permissions']); }), ); diff --git a/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap b/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap index 3c58bdcd92..39b557dab6 100644 --- a/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap +++ b/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap @@ -227,78 +227,55 @@ exports[`components/categories_list should render channels error 1`] = ` There was a problem loading content for this team. - - - Retry - - + Retry + @@ -340,20 +317,75 @@ exports[`components/categories_list should render team error 1`] = ` } } > - - - + + + + + + + + Log out + + + - - - Retry - - + Retry + diff --git a/app/screens/home/channel_list/categories_list/header/header.tsx b/app/screens/home/channel_list/categories_list/header/header.tsx index 9f2a804a99..442d41c30f 100644 --- a/app/screens/home/channel_list/categories_list/header/header.tsx +++ b/app/screens/home/channel_list/categories_list/header/header.tsx @@ -7,14 +7,16 @@ import {Text, View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {logout} from '@actions/remote/session'; import CompassIcon from '@components/compass_icon'; import {ITEM_HEIGHT} from '@components/slide_up_panel_item'; import TouchableWithFeedback from '@components/touchable_with_feedback'; -import {useServerDisplayName} from '@context/server'; +import {useServerDisplayName, useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import {bottomSheet} from '@screens/navigation'; import {bottomSheetSnapPoint} from '@utils/helpers'; +import {alertServerLogout} from '@utils/server'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -61,6 +63,16 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ color: changeOpacity(theme.sidebarText, 0.8), fontSize: 18, }, + noTeamHeadingStyles: { + color: changeOpacity(theme.sidebarText, 0.64), + ...typography('Body', 100, 'SemiBold'), + }, + noTeamHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + height: 40, + }, })); const ChannelListHeader = ({canCreateChannels, canJoinChannels, displayName, iconPad, onHeaderPress}: Props) => { @@ -74,6 +86,7 @@ const ChannelListHeader = ({canCreateChannels, canJoinChannels, displayName, ico const animatedStyle = useAnimatedStyle(() => ({ marginLeft: withTiming(marginLeft.value, {duration: 350}), }), []); + const serverUrl = useServerUrl(); useEffect(() => { marginLeft.value = iconPad ? 44 : 0; @@ -108,51 +121,87 @@ const ChannelListHeader = ({canCreateChannels, canJoinChannels, displayName, ico }); }, [intl, insets, isTablet, theme]); - return ( - - {Boolean(displayName) && - - - - - {displayName} - - - + const onLogoutPress = useCallback(() => { + alertServerLogout(serverDisplayName, () => logout(serverUrl), intl); + }, []); + + let header; + if (displayName) { + header = ( + <> + + + + + {displayName} + + + + - - + + + + + + + {serverDisplayName} + + + ); + } else { + header = ( + + + + {serverDisplayName} + + - + + {intl.formatMessage({id: 'account.logout', defaultMessage: 'Log out'})} + - } - - {serverDisplayName} - + ); + } + + return ( + + {header} ); }; diff --git a/app/screens/home/channel_list/channel_list.tsx b/app/screens/home/channel_list/channel_list.tsx index 648c1ff8f5..2e19569874 100644 --- a/app/screens/home/channel_list/channel_list.tsx +++ b/app/screens/home/channel_list/channel_list.tsx @@ -3,7 +3,7 @@ import {useManagedConfig} from '@mattermost/react-native-emm'; import {useIsFocused, useRoute} from '@react-navigation/native'; -import React from 'react'; +import React, {useEffect} from 'react'; import {StyleSheet} from 'react-native'; import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; @@ -12,6 +12,7 @@ import FreezeScreen from '@components/freeze_screen'; import TeamSidebar from '@components/team_sidebar'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; +import {resetToTeams} from '@screens/navigation'; import AdditionalTabletView from './additional_tablet_view'; import CategoriesList from './categories_list'; @@ -28,9 +29,6 @@ type ChannelProps = { const edges: Edge[] = ['bottom', 'left', 'right']; const styles = StyleSheet.create({ - flex: { - flex: 1, - }, content: { flex: 1, flexDirection: 'row', @@ -69,6 +67,12 @@ const ChannelListScreen = (props: ChannelProps) => { return {height: insets.top, backgroundColor: theme.sidebarBg}; }, [theme]); + useEffect(() => { + if (!props.teamsCount) { + resetToTeams(); + } + }, [Boolean(props.teamsCount)]); + return ( {} diff --git a/app/screens/home/channel_list/index.ts b/app/screens/home/channel_list/index.ts index c3a50e8d2f..9d9463833a 100644 --- a/app/screens/home/channel_list/index.ts +++ b/app/screens/home/channel_list/index.ts @@ -16,7 +16,7 @@ import type {WithDatabaseArgs} from '@typings/database/database'; const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ currentTeamId: observeCurrentTeamId(database), isCRTEnabled: observeIsCRTEnabled(database), - teamsCount: queryMyTeams(database).observeCount(), + teamsCount: queryMyTeams(database).observeCount(false), channelsCount: queryAllMyChannel(database).observeCount(), })); diff --git a/app/screens/home/channel_list/servers/index.tsx b/app/screens/home/channel_list/servers/index.tsx index 1747595705..566bff677f 100644 --- a/app/screens/home/channel_list/servers/index.tsx +++ b/app/screens/home/channel_list/servers/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {IntlShape, useIntl} from 'react-intl'; import {StyleSheet} from 'react-native'; @@ -48,7 +48,11 @@ const sortServers = (servers: ServersModel[], intl: IntlShape) => { }); }; -export default function Servers() { +export type ServersRef = { + openServers: () => void; +} + +const Servers = React.forwardRef((props, ref) => { const intl = useIntl(); const [total, setTotal] = useState({mentions: 0, unread: false}); const registeredServers = useRef(); @@ -136,6 +140,10 @@ export default function Servers() { } }, [isTablet, theme]); + useImperativeHandle(ref, () => ({ + openServers: onPress, + }), [onPress]); + useEffect(() => { const subscription = subscribeAllServers(serversObserver); @@ -157,5 +165,8 @@ export default function Servers() { testID={'channel_list.servers.server_icon'} /> ); -} +}); +Servers.displayName = 'Servers'; + +export default Servers; diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx index d33b0d987b..ec9f9ff9be 100644 --- a/app/screens/home/index.tsx +++ b/app/screens/home/index.tsx @@ -10,6 +10,7 @@ import {enableFreeze, enableScreens} from 'react-native-screens'; import {Events, Screens} from '@constants'; import {useTheme} from '@context/theme'; +import {alertTeamRemove} from '@utils/navigation'; import {notificationError} from '@utils/notification'; import Account from './account'; @@ -47,6 +48,16 @@ export default function HomeScreen(props: HomeProps) { }; }, []); + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.LEAVE_TEAM, (displayName: string) => { + alertTeamRemove(displayName, intl); + }); + + return () => { + listener.remove(); + }; + }); + return ( { case Screens.MFA: screen = withIntl(require('@screens/mfa').default); break; + case Screens.SELECT_TEAM: + screen = withServerDatabase(require('@screens/select_team').default); + break; case Screens.PERMALINK: screen = withServerDatabase(require('@screens/permalink').default); break; diff --git a/app/screens/login/form.tsx b/app/screens/login/form.tsx index 51ac50b1f7..33cb94998e 100644 --- a/app/screens/login/form.tsx +++ b/app/screens/login/form.tsx @@ -16,7 +16,7 @@ import Loading from '@components/loading'; import {FORGOT_PASSWORD, MFA} from '@constants/screens'; import {useIsTablet} from '@hooks/device'; import {t} from '@i18n'; -import {goToScreen, loginAnimationOptions, resetToHome} from '@screens/navigation'; +import {goToScreen, loginAnimationOptions, resetToHome, resetToTeams} from '@screens/navigation'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; import {preventDoubleTap} from '@utils/tap'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -129,8 +129,7 @@ const LoginForm = ({config, extra, keyboardAwareRef, numberSSOs, serverDisplayNa const result: LoginActionResponse = await login(serverUrl!, {serverDisplayName, loginId: loginId.toLowerCase(), password, config, license}); if (checkLoginResponse(result)) { if (!result.hasTeams && !result.error) { - // eslint-disable-next-line no-console - console.log('TODO: GO TO NO TEAMS'); + resetToTeams(); return; } goToHome(result.time || 0, result.error as never); diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 6219ba82cd..971f68cc77 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -275,35 +275,11 @@ export function resetToSelectServer(passProps: LaunchProps) { }); } -export function resetToTeams(name: string, title: string, passProps = {}, options = {}) { +export function resetToTeams() { const theme = getThemeFromState(); const isDark = tinyColor(theme.sidebarBg).isDark(); StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content'); - const defaultOptions = { - layout: { - componentBackgroundColor: theme.centerChannelBg, - }, - statusBar: { - visible: true, - backgroundColor: theme.sidebarBg, - }, - topBar: { - visible: true, - title: { - color: theme.sidebarHeaderTextColor, - text: title, - }, - backButton: { - color: theme.sidebarHeaderTextColor, - title: '', - }, - background: { - color: theme.sidebarBg, - }, - }, - }; - EphemeralStore.clearNavigationComponents(); Navigation.setRoot({ @@ -311,10 +287,28 @@ export function resetToTeams(name: string, title: string, passProps = {}, option stack: { children: [{ component: { - id: name, - name, - passProps, - options: merge(defaultOptions, options), + id: Screens.SELECT_TEAM, + name: Screens.SELECT_TEAM, + options: { + layout: { + componentBackgroundColor: theme.centerChannelBg, + }, + statusBar: { + visible: true, + backgroundColor: theme.sidebarBg, + }, + topBar: { + visible: false, + height: 0, + background: { + color: theme.sidebarBg, + }, + backButton: { + visible: false, + color: theme.sidebarHeaderTextColor, + }, + }, + }, }, }], }, diff --git a/app/screens/select_team/add_team_item.tsx b/app/screens/select_team/add_team_item.tsx new file mode 100644 index 0000000000..bcd0d8f5c5 --- /dev/null +++ b/app/screens/select_team/add_team_item.tsx @@ -0,0 +1,85 @@ +// 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 {Text, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {goToScreen} from '@screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + height: 64, + marginBottom: 2, + }, + touchable: { + display: 'flex', + flexDirection: 'row', + borderRadius: 4, + alignItems: 'center', + height: '100%', + width: '100%', + }, + text: { + color: theme.sidebarText, + marginLeft: 16, + ...typography('Body', 200), + }, + icon_container_container: { + width: 40, + height: 40, + }, + icon_container: { + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: changeOpacity(theme.sidebarText, 0.16), + borderRadius: 10, + }, + icon: { + color: theme.sidebarText, + fontSize: 24, + }, + }; +}); + +export default function AddTeamItem() { + const theme = useTheme(); + const styles = getStyleSheet(theme); + const intl = useIntl(); + const onPress = useCallback(async () => { + // TODO https://mattermost.atlassian.net/browse/MM-43622 + goToScreen(Screens.CREATE_TEAM, 'Create team'); + }, []); + + return ( + + + + + + + + {intl.formatMessage({id: 'mobile.add_team.create_team', defaultMessage: 'Create a new team'})} + + + ); +} diff --git a/app/screens/select_team/header.tsx b/app/screens/select_team/header.tsx new file mode 100644 index 0000000000..ffd6547307 --- /dev/null +++ b/app/screens/select_team/header.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useManagedConfig} from '@mattermost/react-native-emm'; +import React, {useCallback, useMemo, useRef} from 'react'; +import {useIntl} from 'react-intl'; +import {Text, View} from 'react-native'; + +import {logout} from '@actions/remote/session'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {useServerDisplayName, useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {alertServerLogout} from '@utils/server'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import Servers, {ServersRef} from '../home/channel_list/servers'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 20, + marginHorizontal: 24, + }, + text: { + color: changeOpacity(theme.sidebarText, 0.64), + ...typography('Body', 100, 'SemiBold'), + }, +})); + +const MARGIN_WITH_SERVER_ICON = 66; + +function Header() { + const intl = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const serverDisplayName = useServerDisplayName(); + const serverUrl = useServerUrl(); + const managedConfig = useManagedConfig(); + const canAddOtherServers = managedConfig?.allowOtherServers !== 'false'; + const serverButtonRef = useRef(null); + + const headerStyle = useMemo(() => ({...styles.header, marginLeft: canAddOtherServers ? MARGIN_WITH_SERVER_ICON : undefined}), [canAddOtherServers]); + const onLogoutPress = useCallback(() => { + alertServerLogout(serverDisplayName, () => logout(serverUrl), intl); + }, [serverUrl, serverDisplayName]); + + const onLabelPress = useCallback(() => { + serverButtonRef.current?.openServers(); + }, []); + + let serverLabel = ( + + {serverDisplayName} + + ); + if (canAddOtherServers) { + serverLabel = ( + + {serverLabel} + + ); + } + + return ( + <> + {canAddOtherServers && } + + {serverLabel} + + + {intl.formatMessage({id: 'account.logout', defaultMessage: 'Log out'})} + + + + + ); +} + +export default Header; diff --git a/app/screens/select_team/index.ts b/app/screens/select_team/index.ts new file mode 100644 index 0000000000..4e724acf03 --- /dev/null +++ b/app/screens/select_team/index.ts @@ -0,0 +1,34 @@ +// 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 {Permissions} from '@constants'; +import {queryRolesByNames} from '@queries/servers/role'; +import {queryMyTeams} from '@queries/servers/team'; +import {observeCurrentUser} from '@queries/servers/user'; +import {hasPermission} from '@utils/role'; + +import SelectTeam from './select_team'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const canCreateTeams = observeCurrentUser(database).pipe( + switchMap((u) => (u ? of$(u.roles.split(' ')) : of$([]))), + switchMap((values) => queryRolesByNames(database, values).observeWithColumns(['permissions'])), + switchMap((r) => of$(hasPermission(r, Permissions.CREATE_TEAM, false))), + ); + + const nTeams = queryMyTeams(database).observeCount(); + + return { + canCreateTeams, + nTeams, + }; +}); + +export default withDatabase(enhanced(SelectTeam)); diff --git a/app/screens/select_team/no_teams.tsx b/app/screens/select_team/no_teams.tsx new file mode 100644 index 0000000000..4a6e65f37e --- /dev/null +++ b/app/screens/select_team/no_teams.tsx @@ -0,0 +1,106 @@ +// 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 {StyleSheet, Text, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import Empty from '@components/illustrations/no_team'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {useTheme} from '@context/theme'; +import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + marginHorizontal: 24, + maxWidth: 600, + alignSelf: 'center', + }, + iconWrapper: { + height: 120, + width: 120, + backgroundColor: changeOpacity(theme.sidebarText, 0.08), + borderRadius: 60, + justifyContent: 'center', + alignItems: 'center', + }, + title: { + color: theme.sidebarHeaderTextColor, + marginTop: 24, + textAlign: 'center', + ...typography('Heading', 800), + }, + description: { + color: changeOpacity(theme.sidebarText, 0.72), + textAlign: 'center', + marginTop: 12, + ...typography('Body', 200, 'Regular'), + }, + buttonStyle: { + ...StyleSheet.flatten(buttonBackgroundStyle(theme, 'lg', 'primary', 'default')), + flexDirection: 'row', + marginTop: 24, + }, + buttonText: { + ...StyleSheet.flatten(buttonTextStyle(theme, 'lg', 'primary', 'default')), + marginLeft: 8, + }, + plusIcon: { + color: theme.sidebarText, + fontSize: 24, + lineHeight: 22, + }, + +})); + +type Props = { + canCreateTeams: boolean; +} + +const NoTeams = ({ + canCreateTeams, +}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + const intl = useIntl(); + + const onButtonPress = useCallback(async () => { + // TODO https://mattermost.atlassian.net/browse/MM-43622 + //goToScreen(Screens.CREATE_TEAM, 'Create team'); + }, []); + + return ( + + + + + + {intl.formatMessage({id: 'select_team.no_team.title', defaultMessage: 'No teams are available to join'})} + + + {intl.formatMessage({id: 'select_team.no_team.description', defaultMessage: 'To join a team, ask a team admin for an invite, or create your own team. You may also want to check your email inbox for an invitation.'})} + + {canCreateTeams && + + + {intl.formatMessage({id: 'mobile.add_team.create_team', defaultMessage: 'Create a new team'})} + + } + + ); +}; + +export default NoTeams; diff --git a/app/screens/select_team/select_team.tsx b/app/screens/select_team/select_team.tsx new file mode 100644 index 0000000000..9867a8080b --- /dev/null +++ b/app/screens/select_team/select_team.tsx @@ -0,0 +1,108 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; +import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {fetchAllTeams} from '@actions/remote/team'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import {resetToHome} from '../navigation'; + +import Header from './header'; +import NoTeams from './no_teams'; +import TeamList from './team_list'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flex: 1, + backgroundColor: theme.sidebarBg, + }, +})); + +type Props = { + canCreateTeams: boolean; + nTeams: number; +} + +const safeAreaEdges = ['left' as const, 'right' as const]; +const safeAreaStyle = {flex: 1}; + +const SelectTeam = ({ + canCreateTeams, + nTeams, +}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + const serverUrl = useServerUrl(); + const [loading, setLoading] = useState(true); + const insets = useSafeAreaInsets(); + const top = useAnimatedStyle(() => { + return {height: insets.top, backgroundColor: theme.sidebarBg}; + }); + + const mounted = useRef(false); + + const [otherTeams, setOtherTeams] = useState(); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + if (nTeams > 0) { + resetToHome(); + } + }, [nTeams > 0]); + + useEffect(() => { + // eslint-disable-next-line max-nested-callbacks + fetchAllTeams(serverUrl, false).then((r) => { + if (mounted.current) { + setOtherTeams(r.teams || []); + } + // eslint-disable-next-line max-nested-callbacks + }).finally(() => { + if (mounted.current) { + setLoading(false); + } + }); + }, []); + + let body; + if (loading) { + body = null; + } else if (otherTeams?.length) { + body = ( + + ); + } else { + body = (); + } + + return ( + + + +
+ {body} + + + ); +}; + +export default SelectTeam; diff --git a/app/screens/select_team/team_list.tsx b/app/screens/select_team/team_list.tsx new file mode 100644 index 0000000000..429e05c6ae --- /dev/null +++ b/app/screens/select_team/team_list.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React, {useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {Text, View} from 'react-native'; + +import {handleTeamChange} from '@actions/remote/team'; +import TeamFlatList from '@components/team_sidebar/add_team/team_list'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {resetToHome} from '@screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import AddTeamItem from './add_team_item'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flex: 1, + backgroundColor: theme.sidebarBg, + marginHorizontal: 24, + }, + title: { + color: theme.sidebarHeaderTextColor, + marginTop: 40, + ...typography('Heading', 800), + }, + description: { + color: changeOpacity(theme.sidebarText, 0.72), + marginTop: 12, + marginBottom: 25, + ...typography('Body', 200, 'Regular'), + }, + separator: { + borderColor: changeOpacity(theme.sidebarText, 0.08), + borderTopWidth: 1, + marginVertical: 8, + }, +})); + +type Props = { + canCreateTeam: boolean; + teams: Team[]; +}; +function TeamList({ + canCreateTeam, + teams, +}: Props) { + const theme = useTheme(); + const styles = getStyleSheet(theme); + const intl = useIntl(); + const serverUrl = useServerUrl(); + const isTablet = useIsTablet(); + + const onTeamAdded = async (id: string) => { + await handleTeamChange(serverUrl, id); + resetToHome(); + }; + + const containerStyle = useMemo(() => { + return isTablet ? [styles.container, {maxWidth: 600, alignItems: 'center'}] : styles.container; + }, [isTablet, styles]); + + return ( + + + {intl.formatMessage({id: 'select_team.title', defaultMessage: 'Select a team'})} + {intl.formatMessage({id: 'select_team.description', defaultMessage: 'You are not yet a member of any teams. Select one below to get started.'})} + + {canCreateTeam && ( + <> + + + + )} + + + ); +} + +export default TeamList; diff --git a/app/screens/sso/index.tsx b/app/screens/sso/index.tsx index f46cf9b12b..647aca971a 100644 --- a/app/screens/sso/index.tsx +++ b/app/screens/sso/index.tsx @@ -13,7 +13,7 @@ import ClientError from '@client/rest/error'; import {Screens, Sso} from '@constants'; import NetworkManager from '@managers/network_manager'; import Background from '@screens/background'; -import {dismissModal, resetToHome} from '@screens/navigation'; +import {dismissModal, resetToHome, resetToTeams} from '@screens/navigation'; import SSOWithRedirectURL from './sso_with_redirect_url'; import SSOWithWebView from './sso_with_webview'; @@ -102,8 +102,7 @@ const SSO = ({ return; } if (!result.hasTeams && !result.error) { - // eslint-disable-next-line no-console - console.log('GO TO NO TEAMS'); + resetToTeams(); return; } goToHome(result.time || 0, result.error as never); diff --git a/app/store/ephemeral_store.ts b/app/store/ephemeral_store.ts index 1201589095..a33876d526 100644 --- a/app/store/ephemeral_store.ts +++ b/app/store/ephemeral_store.ts @@ -10,6 +10,12 @@ class EphemeralStore { creatingChannel = false; creatingDMorGMTeammates: string[] = []; + // As of today, the server sends a duplicated event to add the user to the team. + // If we do not handle this, this ends up showing some errors in the database, apart + // of the extra computation time. We use this to track the events that are being handled + // and make sure we only handle one. + private addingTeam = new Set(); + addNavigationComponentId = (componentId: string) => { this.addToNavigationComponentIdStack(componentId); this.addToAllNavigationComponentIds(componentId); @@ -114,6 +120,18 @@ class EphemeralStore { found = !this.navigationComponentIdStack.includes(componentId); } }; + + startAddingToTeam = (teamId: string) => { + this.addingTeam.add(teamId); + }; + + finishAddingToTeam = (teamId: string) => { + this.addingTeam.delete(teamId); + }; + + isAddingToTeam = (teamId: string) => { + return this.addingTeam.has(teamId); + }; } export default new EphemeralStore(); diff --git a/app/utils/buttonStyles.ts b/app/utils/buttonStyles.ts index 8fdc4ee801..2e8c962e43 100644 --- a/app/utils/buttonStyles.ts +++ b/app/utils/buttonStyles.ts @@ -421,7 +421,7 @@ export const buttonTextStyle = ( }, lg: { fontSize: 16, - lineHeight: 16, + lineHeight: 18, marginTop: 1, }, }); diff --git a/app/utils/navigation/index.ts b/app/utils/navigation/index.ts index fcf5c544cb..a2d7023d93 100644 --- a/app/utils/navigation/index.ts +++ b/app/utils/navigation/index.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {IntlShape} from 'react-intl'; +import {Alert} from 'react-native'; import {Navigation, Options} from 'react-native-navigation'; import {Screens} from '@constants'; @@ -10,3 +12,20 @@ export const appearanceControlledScreens = new Set([Screens.SERVER, Screens.LOGI export function mergeNavigationOptions(componentId: string, options: Options) { Navigation.mergeOptions(componentId, options); } + +export async function alertTeamRemove(displayName: string, intl: IntlShape) { + Alert.alert( + intl.formatMessage({ + id: 'alert.removed_from_team.title', + defaultMessage: 'Removed from team', + }), + intl.formatMessage({ + id: 'alert.removed_from_team.description', + defaultMessage: 'You have been removed from team {displayName}.', + }, {displayName}), + [{ + style: 'cancel', + text: intl.formatMessage({id: 'mobile.oauth.something_wrong.okButton', defaultMessage: 'OK'}), + }], + ); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 386195bb63..863424caaa 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -15,6 +15,8 @@ "account.settings": "Settings", "account.user_status.title": "User Presence", "account.your_profile": "Your Profile", + "alert.removed_from_team.description": "You have been removed from team {displayName}.", + "alert.removed_from_team.title": "Removed from team", "api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.", "api.channel.add_member.added": "{addedUsername} added to the channel by {username}.", "api.channel.guest_join_channel.post_and_forget": "{username} joined the channel as a guest.", @@ -277,7 +279,7 @@ "mobile.about.serverVersionNoBuild": "Server Version: {version}", "mobile.account.settings.save": "Save", "mobile.action_menu.select": "Select an option", - "mobile.add_team.create_team": "Create a New Team", + "mobile.add_team.create_team": "Create a new team", "mobile.add_team.join_team": "Join Another Team", "mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.", "mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos", @@ -525,6 +527,10 @@ "screens.channel_details": "Channel Details", "screens.channel_edit_header": "Edit Channel Header", "search_bar.search": "Search", + "select_team.description": "You are not yet a member of any teams. Select one below to get started.", + "select_team.no_team.description": "To join a team, ask a team admin for an invite, or create your own team. You may also want to check your email inbox for an invitation.", + "select_team.no_team.title": "No teams are available to join", + "select_team.title": "Select a team", "server.logout.alert_description": "All associated data will be removed", "server.logout.alert_title": "Are you sure you want to log out of {displayName}?", "server.remove.alert_description": "This will remove it from your list of servers. All associated data will be removed",