diff --git a/app/actions/remote/entry/common.ts b/app/actions/remote/entry/common.ts index 3fae907da5..35ea06cd2f 100644 --- a/app/actions/remote/entry/common.ts +++ b/app/actions/remote/entry/common.ts @@ -9,7 +9,7 @@ import {fetchPostsForUnreadChannels} from '@actions/remote/post'; import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference'; import {fetchRoles} from '@actions/remote/role'; import {fetchConfigAndLicense} from '@actions/remote/systems'; -import {fetchAllTeams, fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, MyTeamsRequest} from '@actions/remote/team'; +import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team'; import {syncTeamThreads} from '@actions/remote/thread'; import {autoUpdateTimezone, fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user'; import {gqlAllChannels} from '@client/graphQL/entry'; @@ -343,7 +343,7 @@ export async function restDeferredAppEntryActions( } } - await fetchAllTeams(serverUrl); + updateCanJoinTeams(serverUrl); await updateAllUsersSince(serverUrl, since); // Fetch groups for current user diff --git a/app/actions/remote/entry/gql_common.ts b/app/actions/remote/entry/gql_common.ts index b2db28b107..3006f2816c 100644 --- a/app/actions/remote/entry/gql_common.ts +++ b/app/actions/remote/entry/gql_common.ts @@ -7,7 +7,7 @@ import {storeConfigAndLicense} from '@actions/local/systems'; import {MyChannelsRequest} from '@actions/remote/channel'; import {fetchGroupsForMember} from '@actions/remote/groups'; import {fetchPostsForUnreadChannels} from '@actions/remote/post'; -import {MyTeamsRequest} from '@actions/remote/team'; +import {MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team'; import {syncTeamThreads} from '@actions/remote/thread'; import {autoUpdateTimezone, updateAllUsersSince} from '@actions/remote/user'; import {gqlEntry, gqlEntryChannels, gqlOtherChannels} from '@client/graphQL/entry'; @@ -95,6 +95,7 @@ export async function deferredAppEntryGraphQLActions( // Fetch groups for current user fetchGroupsForMember(serverUrl, currentUserId); + updateCanJoinTeams(serverUrl); updateAllUsersSince(serverUrl, since); return {error: undefined}; @@ -180,9 +181,22 @@ export const entryGQL = async (serverUrl: string, currentTeamId?: string, curren user: gqlToClientUser(fetchedData.user!), }; + const allTeams = getMemberTeamsFromGQLQuery(fetchedData); + const allTeamMemberships = fetchedData.teamMembers.map((m) => gqlToClientTeamMembership(m, meData.user.id)); + + const [nonArchivedTeams, archivedTeamIds] = allTeams.reduce((acc, t) => { + if (t.delete_at) { + acc[1].add(t.id); + return acc; + } + return [[...acc[0], t], acc[1]]; + }, [[], new Set()]); + + const nonArchivedTeamMemberships = allTeamMemberships.filter((m) => !archivedTeamIds.has(m.team_id)); + const teamData = { - teams: getMemberTeamsFromGQLQuery(fetchedData), - memberships: fetchedData.teamMembers.map((m) => gqlToClientTeamMembership(m, meData.user.id)), + teams: nonArchivedTeams, + memberships: nonArchivedTeamMemberships, }; const prefData = { @@ -202,7 +216,7 @@ export const entryGQL = async (serverUrl: string, currentTeamId?: string, curren let initialTeamId = currentTeamId; if (!teamData.teams.length) { initialTeamId = ''; - } else if (!initialTeamId || !teamData.teams.find((t) => t.id === currentTeamId)) { + } else if (!initialTeamId || !teamData.teams.find((t) => t.id === currentTeamId && t.delete_at === 0)) { const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.TEAMS_ORDER, '', '') as string; initialTeamId = selectDefaultTeam(teamData.teams, meData.user.locale, teamOrderPreference, config.ExperimentalPrimaryTeam)?.id || ''; } diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index 6adf5acfee..61968e3b10 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -25,6 +25,7 @@ import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers import {queryAllUsers} from '@queries/servers/user'; import {setFetchingThreadState} from '@store/fetching_thread_store'; import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers'; +import {isServerError} from '@utils/errors'; import {logError} from '@utils/log'; import {processPostsFetched} from '@utils/post'; import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list'; @@ -134,7 +135,7 @@ export async function createPost(serverUrl: string, post: Partial, files: let created; try { created = await client.createPost(newPost); - } catch (error: any) { + } catch (error) { const errorPost = { ...newPost, id: pendingPostId, @@ -147,10 +148,11 @@ export async function createPost(serverUrl: string, post: Partial, files: // If the failure was because: the root post was deleted or // TownSquareIsReadOnly=true then remove the post - if (error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR || + if (isServerError(error) && ( + error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR || error.server_error_id === ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR || error.server_error_id === ServerErrors.PLUGIN_DISMISSED_POST_ERROR - ) { + )) { await removePost(serverUrl, databasePost); } else { const models = await operator.handlePosts({ @@ -252,11 +254,12 @@ export const retryFailedPost = async (serverUrl: string, post: PostModel) => { } } await operator.batchRecords(models); - } catch (error: any) { - if (error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR || + } catch (error) { + if (isServerError(error) && ( + error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR || error.server_error_id === ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR || error.server_error_id === ServerErrors.PLUGIN_DISMISSED_POST_ERROR - ) { + )) { await removePost(serverUrl, post); } else { post.prepareUpdate((p) => { diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index c23ece7cb6..1199288a97 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -5,6 +5,8 @@ import {Model} from '@nozbe/watermelondb'; import {DeviceEventEmitter} from 'react-native'; import {removeUserFromTeam as localRemoveUserFromTeam} from '@actions/local/team'; +import {Client} from '@client/rest'; +import {PER_PAGE_DEFAULT} from '@client/rest/constants'; import {Events} from '@constants'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; @@ -12,8 +14,8 @@ import {getActiveServerUrl} from '@queries/app/servers'; import {prepareCategoriesAndCategoriesChannels} from '@queries/servers/categories'; import {prepareMyChannelsForTeam, getDefaultChannelForTeam} from '@queries/servers/channel'; import {prepareCommonSystemValues, getCurrentTeamId, getCurrentUserId} from '@queries/servers/system'; -import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, getNthLastChannelFromTeam, queryTeamsById, syncTeamTable, getLastTeam, getTeamById, removeTeamFromTeamHistory} from '@queries/servers/team'; -import {dismissAllModals, popToRoot, resetToTeams} from '@screens/navigation'; +import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, getNthLastChannelFromTeam, queryTeamsById, getLastTeam, getTeamById, removeTeamFromTeamHistory, queryMyTeams} from '@queries/servers/team'; +import {dismissAllModals, popToRoot} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import {setTeamLoading} from '@store/team_load_store'; import {isTablet} from '@utils/helpers'; @@ -100,6 +102,7 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s } } EphemeralStore.finishAddingToTeam(teamId); + updateCanJoinTeams(serverUrl); return {member}; } catch (error) { if (loadEventSent) { @@ -195,23 +198,10 @@ export async function fetchMyTeam(serverUrl: string, teamId: string, fetchOnly = } } -export const fetchAllTeams = async (serverUrl: string, fetchOnly = false): Promise => { - let client; +export const fetchAllTeams = async (serverUrl: string, page = 0, perPage = PER_PAGE_DEFAULT): Promise<{teams?: Team[]; error?: any}> => { 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) { - syncTeamTable(operator, teams); - } - } - + const client = NetworkManager.getClient(serverUrl); + const teams = await client.getTeams(page, perPage); return {teams}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientError); @@ -219,6 +209,76 @@ export const fetchAllTeams = async (serverUrl: string, fetchOnly = false): Promi } }; +const recCanJoinTeams = async (client: Client, myTeamsIds: Set, page: number): Promise => { + const fetchedTeams = await client.getTeams(page, PER_PAGE_DEFAULT); + if (fetchedTeams.find((t) => !myTeamsIds.has(t.id) && t.delete_at === 0)) { + return true; + } + + if (fetchedTeams.length === PER_PAGE_DEFAULT) { + return recCanJoinTeams(client, myTeamsIds, page + 1); + } + + return false; +}; + +const LOAD_MORE_THRESHOLD = 10; +export async function fetchTeamsForComponent( + serverUrl: string, + page: number, + joinedIds?: Set, + alreadyLoaded: Team[] = [], +): Promise<{teams: Team[]; hasMore: boolean; page: number}> { + let hasMore = true; + const {teams, error} = await fetchAllTeams(serverUrl, page, PER_PAGE_DEFAULT); + if (error || !teams || teams.length < PER_PAGE_DEFAULT) { + hasMore = false; + } + + if (error) { + return {teams: alreadyLoaded, hasMore, page}; + } + + if (teams?.length) { + const notJoinedTeams = joinedIds ? teams.filter((t) => !joinedIds.has(t.id)) : teams; + alreadyLoaded.push(...notJoinedTeams); + + if (teams.length < PER_PAGE_DEFAULT) { + hasMore = false; + } + + if ( + hasMore && + (alreadyLoaded.length > LOAD_MORE_THRESHOLD) + ) { + return fetchTeamsForComponent(serverUrl, page + 1, joinedIds, alreadyLoaded); + } + + return {teams: alreadyLoaded, hasMore, page: page + 1}; + } + + return {teams: alreadyLoaded, hasMore: false, page}; +} + +export const updateCanJoinTeams = async (serverUrl: string) => { + try { + const client = NetworkManager.getClient(serverUrl); + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const myTeams = await queryMyTeams(database).fetch(); + const myTeamsIds = new Set(myTeams.map((m) => m.id)); + + const canJoin = await recCanJoinTeams(client, myTeamsIds, 0); + + EphemeralStore.setCanJoinOtherTeams(serverUrl, canJoin); + return {}; + } catch (error) { + EphemeralStore.setCanJoinOtherTeams(serverUrl, false); + forceLogoutIfNecessary(serverUrl, error as ClientError); + return {error}; + } +}; + export const fetchTeamsChannelsAndUnreadPosts = async (serverUrl: string, since: number, teams: Team[], memberships: TeamMembership[], excludeTeamId?: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { @@ -288,7 +348,7 @@ export const removeUserFromTeam = async (serverUrl: string, teamId: string, user if (!fetchOnly) { localRemoveUserFromTeam(serverUrl, teamId); - fetchAllTeams(serverUrl); + updateCanJoinTeams(serverUrl); } return {error: undefined}; @@ -361,9 +421,9 @@ export async function handleKickFromTeam(serverUrl: string, teamId: string) { const teamToJumpTo = await getLastTeam(database, teamId); if (teamToJumpTo) { await handleTeamChange(serverUrl, teamToJumpTo); - } else if (currentServer === serverUrl) { - await resetToTeams(); } + + // Resetting to team select handled by the home screen } catch (error) { logDebug('Failed to kick user from team', error); } diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index f2b12615b3..978c6d4891 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -68,7 +68,7 @@ import {handlePreferenceChangedEvent, handlePreferencesChangedEvent, handlePrefe import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions'; import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles'; import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system'; -import {handleLeaveTeamEvent, handleUserAddedToTeamEvent, handleUpdateTeamEvent} from './teams'; +import {handleLeaveTeamEvent, handleUserAddedToTeamEvent, handleUpdateTeamEvent, handleTeamArchived, handleTeamRestored} from './teams'; import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads'; import {handleUserUpdatedEvent, handleUserTypingEvent} from './users'; @@ -312,6 +312,14 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { handleOpenDialogEvent(serverUrl, msg); break; + case WebsocketEvents.DELETE_TEAM: + handleTeamArchived(serverUrl, msg); + break; + + case WebsocketEvents.RESTORE_TEAM: + handleTeamRestored(serverUrl, msg); + break; + case WebsocketEvents.THREAD_UPDATED: handleThreadUpdatedEvent(serverUrl, msg); break; diff --git a/app/actions/websocket/teams.ts b/app/actions/websocket/teams.ts index 3602f94cee..c59d62d385 100644 --- a/app/actions/websocket/teams.ts +++ b/app/actions/websocket/teams.ts @@ -6,17 +6,77 @@ import {Model} from '@nozbe/watermelondb'; import {removeUserFromTeam} from '@actions/local/team'; import {fetchMyChannelsForTeam} from '@actions/remote/channel'; import {fetchRoles} from '@actions/remote/role'; -import {fetchAllTeams, fetchMyTeam, handleKickFromTeam} from '@actions/remote/team'; +import {fetchMyTeam, handleKickFromTeam, updateCanJoinTeams} from '@actions/remote/team'; import {updateUsersNoLongerVisible} from '@actions/remote/user'; import DatabaseManager from '@database/manager'; +import ServerDataOperator from '@database/operator/server_data_operator'; +import NetworkManager from '@managers/network_manager'; import {prepareCategoriesAndCategoriesChannels} from '@queries/servers/categories'; import {prepareMyChannelsForTeam} from '@queries/servers/channel'; -import {getCurrentTeam, prepareMyTeams} from '@queries/servers/team'; +import {getCurrentTeam, prepareMyTeams, queryMyTeamsByIds} from '@queries/servers/team'; import {getCurrentUser} from '@queries/servers/user'; import EphemeralStore from '@store/ephemeral_store'; import {setTeamLoading} from '@store/team_load_store'; import {logDebug} from '@utils/log'; +export async function handleTeamArchived(serverUrl: string, msg: WebSocketMessage) { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const team: Team = JSON.parse(msg.data.team); + + const membership = (await queryMyTeamsByIds(database, [team.id]).fetch())[0]; + if (membership) { + const currentTeam = await getCurrentTeam(database); + if (currentTeam?.id === team.id) { + await handleKickFromTeam(serverUrl, team.id); + } + + await removeUserFromTeam(serverUrl, team.id); + + const user = await getCurrentUser(database); + if (user?.isGuest) { + updateUsersNoLongerVisible(serverUrl); + } + } + updateCanJoinTeams(serverUrl); + } catch (error) { + logDebug('cannot handle archive team websocket event', error); + } +} + +export async function handleTeamRestored(serverUrl: string, msg: WebSocketMessage) { + let markedAsLoading = false; + try { + const client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const team: Team = JSON.parse(msg.data.team); + + const teamMembership = await client.getTeamMember(team.id, 'me'); + if (teamMembership && teamMembership.delete_at === 0) { + // Ignore duplicated team join events sent by the server + if (EphemeralStore.isAddingToTeam(team.id)) { + return; + } + EphemeralStore.startAddingToTeam(team.id); + + setTeamLoading(serverUrl, true); + markedAsLoading = true; + await fetchAndStoreJoinedTeamInfo(serverUrl, operator, team.id, [team], [teamMembership]); + setTeamLoading(serverUrl, false); + markedAsLoading = false; + + EphemeralStore.finishAddingToTeam(team.id); + } + + updateCanJoinTeams(serverUrl); + } catch (error) { + if (markedAsLoading) { + setTeamLoading(serverUrl, false); + } + logDebug('cannot handle restore team websocket event', error); + } +} + export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMessage) { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -34,7 +94,7 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess } await removeUserFromTeam(serverUrl, teamId); - fetchAllTeams(serverUrl); + updateCanJoinTeams(serverUrl); if (user.isGuest) { updateUsersNoLongerVisible(serverUrl); @@ -76,27 +136,31 @@ export async function handleUserAddedToTeamEvent(serverUrl: string, msg: WebSock const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const {teams, memberships: teamMemberships} = await fetchMyTeam(serverUrl, teamId, true); - const modelPromises: Array> = []; - if (teams?.length && teamMemberships?.length) { - const {channels, memberships, categories} = await fetchMyChannelsForTeam(serverUrl, teamId, false, 0, true); - modelPromises.push(prepareCategoriesAndCategoriesChannels(operator, categories || [], true)); - 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(operator, teams, teamMemberships)); - } - - const models = await Promise.all(modelPromises); - await operator.batchRecords(models.flat()); - setTeamLoading(serverUrl, false); + await fetchAndStoreJoinedTeamInfo(serverUrl, operator, teamId, teams, teamMemberships); } catch (error) { logDebug('could not handle user added to team websocket event'); - setTeamLoading(serverUrl, false); } - + setTeamLoading(serverUrl, false); EphemeralStore.finishAddingToTeam(teamId); } + +const fetchAndStoreJoinedTeamInfo = async (serverUrl: string, operator: ServerDataOperator, teamId: string, teams?: Team[], teamMemberships?: TeamMembership[]) => { + const modelPromises: Array> = []; + if (teams?.length && teamMemberships?.length) { + const {channels, memberships, categories} = await fetchMyChannelsForTeam(serverUrl, teamId, false, 0, true); + modelPromises.push(prepareCategoriesAndCategoriesChannels(operator, categories || [], true)); + modelPromises.push(...await prepareMyChannelsForTeam(operator, teamId, channels || [], memberships || [])); + + const {roles} = await fetchRoles(serverUrl, teamMemberships, memberships, undefined, true); + if (roles?.length) { + modelPromises.push(operator.handleRole({roles, prepareRecordsOnly: true})); + } + } + + if (teams && teamMemberships) { + modelPromises.push(...prepareMyTeams(operator, teams, teamMemberships)); + } + + const models = await Promise.all(modelPromises); + await operator.batchRecords(models.flat()); +}; diff --git a/app/components/team_sidebar/add_team/team_list.tsx b/app/components/team_list/index.tsx similarity index 83% rename from app/components/team_sidebar/add_team/team_list.tsx rename to app/components/team_list/index.tsx index 28da68a3c3..08c4738adf 100644 --- a/app/components/team_sidebar/add_team/team_list.tsx +++ b/app/components/team_list/index.tsx @@ -5,6 +5,8 @@ import React, {useCallback} from 'react'; import {ListRenderItemInfo, StyleSheet, 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 Loading from '@components/loading'; + import TeamListItem from './team_list_item'; import type TeamModel from '@typings/database/models/servers/team'; @@ -17,6 +19,8 @@ type Props = { onPress: (id: string) => void; testID?: string; selectedTeamId?: string; + onEndReached?: () => void; + loading?: boolean; } const styles = StyleSheet.create({ @@ -30,7 +34,7 @@ const styles = StyleSheet.create({ const keyExtractor = (item: TeamModel) => item.id; -export default function TeamList({teams, textColor, iconTextColor, iconBackgroundColor, onPress, testID, selectedTeamId}: Props) { +export default function TeamList({teams, textColor, iconTextColor, iconBackgroundColor, onPress, testID, selectedTeamId, onEndReached, loading = false}: Props) { const renderTeam = useCallback(({item: t}: ListRenderItemInfo) => { return ( ); + } + return ( ); diff --git a/app/components/team_sidebar/add_team/team_list_item/index.ts b/app/components/team_list/team_list_item/index.ts similarity index 100% rename from app/components/team_sidebar/add_team/team_list_item/index.ts rename to app/components/team_list/team_list_item/index.ts diff --git a/app/components/team_sidebar/add_team/team_list_item/team_list_item.tsx b/app/components/team_list/team_list_item/team_list_item.tsx similarity index 100% rename from app/components/team_sidebar/add_team/team_list_item/team_list_item.tsx rename to app/components/team_list/team_list_item/team_list_item.tsx 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 deleted file mode 100644 index 2e41bee57b..0000000000 --- a/app/components/team_sidebar/add_team/add_team_slide_up.tsx +++ /dev/null @@ -1,105 +0,0 @@ -// 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 {View} from 'react-native'; - -import {addCurrentUserToTeam, handleTeamChange} from '@actions/remote/team'; -import FormattedText from '@components/formatted_text'; -import Empty from '@components/illustrations/no_team'; -import {useServerUrl} from '@context/server'; -import {useTheme} from '@context/theme'; -import BottomSheetContent from '@screens/bottom_sheet/content'; -import {dismissBottomSheet} from '@screens/navigation'; -import {makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; - -import TeamList from './team_list'; - -import type TeamModel from '@typings/database/models/servers/team'; - -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ - empty: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - title: { - color: theme.centerChannelColor, - lineHeight: 28, - marginTop: 16, - ...typography('Heading', 400, 'Regular'), - }, - description: { - color: theme.centerChannelColor, - marginTop: 8, - maxWidth: 334, - ...typography('Body', 200, 'Regular'), - }, -})); - -type Props = { - otherTeams: TeamModel[]; - title: string; - showTitle?: boolean; -} - -export default function AddTeamSlideUp({otherTeams, title, showTitle = true}: Props) { - const intl = useIntl(); - const serverUrl = useServerUrl(); - const theme = useTheme(); - const styles = getStyleSheet(theme); - - const onPressCreate = useCallback(() => { - //TODO Create team screen https://mattermost.atlassian.net/browse/MM-43622 - dismissBottomSheet(); - }, []); - - const onPress = useCallback(async (teamId: string) => { - const {error} = await addCurrentUserToTeam(serverUrl, teamId); - if (!error) { - await dismissBottomSheet(); - handleTeamChange(serverUrl, teamId); - } - }, [serverUrl]); - - const hasOtherTeams = Boolean(otherTeams.length); - - return ( - - {hasOtherTeams && - - } - {!hasOtherTeams && - - - - - - } - - ); -} diff --git a/app/components/team_sidebar/add_team/index.tsx b/app/components/team_sidebar/add_team/index.tsx index e361ef5ddb..9fd187239f 100644 --- a/app/components/team_sidebar/add_team/index.tsx +++ b/app/components/team_sidebar/add_team/index.tsx @@ -3,26 +3,16 @@ import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import {useWindowDimensions, View} from 'react-native'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {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 {useIsTablet} from '@hooks/device'; -import {bottomSheet} from '@screens/navigation'; +import {showModal} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; -import {getTeamsSnapHeight} from '@utils/team_list'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import AddTeamSlideUp from './add_team_slide_up'; - -import type TeamModel from '@typings/database/models/servers/team'; - -type Props = { - otherTeams: TeamModel[]; -} - const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { container: { @@ -45,35 +35,26 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -export default function AddTeam({otherTeams}: Props) { +export default function AddTeam() { const theme = useTheme(); const styles = getStyleSheet(theme); - const dimensions = useWindowDimensions(); const intl = useIntl(); - const insets = useSafeAreaInsets(); - const isTablet = useIsTablet(); const onPress = useCallback(preventDoubleTap(() => { const title = intl.formatMessage({id: 'mobile.add_team.join_team', defaultMessage: 'Join Another Team'}); - const renderContent = () => { - return ( - - ); + const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + const closeButtonId = 'close-join-team'; + const options = { + topBar: { + leftButtons: [{ + id: closeButtonId, + icon: closeButton, + testID: 'close.join_team.button', + }], + }, }; - - const height = getTeamsSnapHeight({dimensions, teams: otherTeams, insets}); - bottomSheet({ - closeButtonId: 'close-team_list', - renderContent, - snapPoints: [height, 10], - theme, - title, - }); - }), [otherTeams, intl, isTablet, dimensions, theme]); + showModal(Screens.JOIN_TEAM, title, {closeButtonId}, options); + }), [intl]); return ( diff --git a/app/components/team_sidebar/index.ts b/app/components/team_sidebar/index.ts index 55561115ef..8c4a8417d1 100644 --- a/app/components/team_sidebar/index.ts +++ b/app/components/team_sidebar/index.ts @@ -1,17 +1,14 @@ // 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 {switchMap} from 'rxjs/operators'; -import {queryMyTeams, queryOtherTeams} from '@queries/servers/team'; +import {withServerUrl} from '@context/server'; +import EphemeralStore from '@store/ephemeral_store'; import TeamSidebar from './team_sidebar'; -import type {WithDatabaseArgs} from '@typings/database/database'; - -const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { +const enhanced = withObservables([], ({serverUrl}: {serverUrl: string}) => { // TODO https://mattermost.atlassian.net/browse/MM-43622 // const canCreateTeams = observeCurrentUser(database).pipe( // switchMap((u) => (u ? of$(u.roles.split(' ')) : of$([]))), @@ -19,17 +16,9 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { // switchMap((r) => of$(hasPermission(r, Permissions.CREATE_TEAM, false))), // ); - const otherTeams = queryMyTeams(database).observe().pipe( - switchMap((mm) => { - // eslint-disable-next-line max-nested-callbacks - const ids = mm.map((m) => m.id); - return queryOtherTeams(database, ids).observe(); - }), - ); - return { - otherTeams, + canJoinOtherTeams: EphemeralStore.observeCanJoinOtherTeams(serverUrl), }; }); -export default withDatabase(enhanced(TeamSidebar)); +export default withServerUrl(enhanced(TeamSidebar)); diff --git a/app/components/team_sidebar/team_sidebar.tsx b/app/components/team_sidebar/team_sidebar.tsx index 50a84181d8..bab5239ee8 100644 --- a/app/components/team_sidebar/team_sidebar.tsx +++ b/app/components/team_sidebar/team_sidebar.tsx @@ -11,11 +11,9 @@ import {makeStyleSheetFromTheme} from '@utils/theme'; import AddTeam from './add_team'; import TeamList from './team_list'; -import type TeamModel from '@typings/database/models/servers/team'; - type Props = { iconPad?: boolean; - otherTeams: TeamModel[]; + canJoinOtherTeams: boolean; teamsCount: number; } @@ -38,8 +36,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -export default function TeamSidebar({iconPad, otherTeams, teamsCount}: Props) { - const showAddTeam = otherTeams.length > 0; +export default function TeamSidebar({iconPad, canJoinOtherTeams, teamsCount}: Props) { const initialWidth = teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0; const width = useSharedValue(initialWidth); const marginTop = useSharedValue(iconPad ? 44 : 0); @@ -68,10 +65,8 @@ export default function TeamSidebar({iconPad, otherTeams, teamsCount}: Props) { - {showAddTeam && ( - + {canJoinOtherTeams && ( + )} diff --git a/app/constants/screens.ts b/app/constants/screens.ts index a5b40dc78d..1f6713989a 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -29,6 +29,7 @@ export const HOME = 'Home'; export const INTEGRATION_SELECTOR = 'IntegrationSelector'; export const INTERACTIVE_DIALOG = 'InteractiveDialog'; export const IN_APP_NOTIFICATION = 'InAppNotification'; +export const JOIN_TEAM = 'JoinTeam'; export const LATEX = 'Latex'; export const LOGIN = 'Login'; export const MENTIONS = 'Mentions'; @@ -94,6 +95,7 @@ export default { INTEGRATION_SELECTOR, INTERACTIVE_DIALOG, IN_APP_NOTIFICATION, + JOIN_TEAM, LATEX, LOGIN, MENTIONS, diff --git a/app/constants/server_errors.ts b/app/constants/server_errors.ts index cca5063561..9799970b70 100644 --- a/app/constants/server_errors.ts +++ b/app/constants/server_errors.ts @@ -5,4 +5,5 @@ export default { DELETED_ROOT_POST_ERROR: 'api.post.create_post.root_id.app_error', TOWN_SQUARE_READ_ONLY_ERROR: 'api.post.create_post.town_square_read_only', PLUGIN_DISMISSED_POST_ERROR: 'plugin.message_will_be_posted.dismiss_post', + TEAM_MEMBERSHIP_DENIAL_ERROR_ID: 'api.team.add_members.user_denied', }; diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 465cd55aaf..d7a3e90dde 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -51,6 +51,8 @@ const WebsocketEvents = { THREAD_UPDATED: 'thread_updated', THREAD_FOLLOW_CHANGED: 'thread_follow_changed', THREAD_READ_CHANGED: 'thread_read_changed', + DELETE_TEAM: 'delete_team', + RESTORE_TEAM: 'restore_team', APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings', CALLS_CHANNEL_ENABLED: `custom_${Calls.PluginId}_channel_enable_voice`, CALLS_CHANNEL_DISABLED: `custom_${Calls.PluginId}_channel_disable_voice`, diff --git a/app/queries/servers/categories.ts b/app/queries/servers/categories.ts index 2210143ba9..7d80a86117 100644 --- a/app/queries/servers/categories.ts +++ b/app/queries/servers/categories.ts @@ -9,6 +9,7 @@ import {FAVORITES_CATEGORY} from '@constants/categories'; import {MM_TABLES} from '@constants/database'; import {makeCategoryChannelId} from '@utils/categories'; import {pluckUnique} from '@utils/helpers'; +import {logDebug} from '@utils/log'; import {observeChannelsByLastPostAt} from './channel'; @@ -38,16 +39,10 @@ export const queryCategoriesByTeamIds = (database: Database, teamIds: string[]) export async function prepareCategoriesAndCategoriesChannels(operator: ServerDataOperator, categories: CategoryWithChannels[], prune = false) { try { - const modelPromises: Array> = []; - const preparedCategories = prepareCategories(operator, categories); - if (preparedCategories) { - modelPromises.push(preparedCategories); - } - - const preparedCategoryChannels = prepareCategoryChannels(operator, categories); - if (preparedCategoryChannels) { - modelPromises.push(preparedCategoryChannels); - } + const modelPromises: Array> = [ + prepareCategories(operator, categories), + prepareCategoryChannels(operator, categories), + ]; const models = await Promise.all(modelPromises); const flattenedModels = models.flat(); @@ -71,7 +66,8 @@ export async function prepareCategoriesAndCategoriesChannels(operator: ServerDat } return flattenedModels; - } catch { + } catch (error) { + logDebug('error while preparing categories and categories channels', error); return []; } } diff --git a/app/queries/servers/team.ts b/app/queries/servers/team.ts index 4f00b776b0..a148c3c597 100644 --- a/app/queries/servers/team.ts +++ b/app/queries/servers/team.ts @@ -165,31 +165,6 @@ export const getLastTeam = async (database: Database, ignoreIdForDefault?: strin return getDefaultTeamId(database, ignoreIdForDefault); }; -export async function syncTeamTable(operator: ServerDataOperator, teams: Team[]) { - try { - const deletedTeams = teams.filter((t) => t.delete_at > 0).map((t) => t.id); - const deletedSet = new Set(deletedTeams); - const availableTeams = teams.filter((a) => !deletedSet.has(a.id)); - const models = []; - - if (deletedTeams.length) { - const notAvailable = await operator.database.get(TEAM).query(Q.where('id', Q.oneOf(deletedTeams))).fetch(); - const deletions = await Promise.all(notAvailable.map((t) => prepareDeleteTeam(t))); - for (const d of deletions) { - models.push(...d); - } - } - - models.push(...await operator.handleTeam({teams: availableTeams, prepareRecordsOnly: true})); - if (models.length) { - await operator.batchRecords(models); - } - return {}; - } catch (error) { - return {error}; - } -} - export const getDefaultTeamId = async (database: Database, ignoreId?: string) => { const user = await getCurrentUser(database); const config = await getConfig(database); diff --git a/app/screens/gallery/video_renderer/error.tsx b/app/screens/gallery/video_renderer/error.tsx index 6d88f8fa87..ab47ed7eab 100644 --- a/app/screens/gallery/video_renderer/error.tsx +++ b/app/screens/gallery/video_renderer/error.tsx @@ -7,13 +7,13 @@ import FastImage from 'react-native-fast-image'; import {RectButton, TouchableWithoutFeedback} from 'react-native-gesture-handler'; import Animated from 'react-native-reanimated'; -import {typography} from '@app/utils/typography'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import {Preferences} from '@constants'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; import {calculateDimensions} from '@utils/images'; import {changeOpacity} from '@utils/theme'; +import {typography} from '@utils/typography'; const styles = StyleSheet.create({ container: { diff --git a/app/screens/home/channel_list/index.ts b/app/screens/home/channel_list/index.ts index 9773d664c3..27064044d8 100644 --- a/app/screens/home/channel_list/index.ts +++ b/app/screens/home/channel_list/index.ts @@ -25,7 +25,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { isCRTEnabled: observeIsCRTEnabled(database), teamsCount: queryMyTeams(database).observeCount(false), channelsCount: observeCurrentTeamId(database).pipe( - switchMap((id) => (id ? queryAllMyChannelsForTeam(database, id).observeCount() : of$(0))), + switchMap((id) => (id ? queryAllMyChannelsForTeam(database, id).observeCount(false) : of$(0))), ), isLicensed, showToS: observeShowToS(database), diff --git a/app/screens/home/search/select_team_slideup.tsx b/app/screens/home/search/select_team_slideup.tsx index d30794baff..e768940931 100644 --- a/app/screens/home/search/select_team_slideup.tsx +++ b/app/screens/home/search/select_team_slideup.tsx @@ -3,7 +3,7 @@ import React, {useCallback} from 'react'; -import TeamList from '@components/team_sidebar/add_team/team_list'; +import TeamList from '@components/team_list'; import {useIsTablet} from '@hooks/device'; import BottomSheetContent from '@screens/bottom_sheet/content'; import {dismissBottomSheet} from '@screens/navigation'; diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 308242f719..607ca808f8 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -137,6 +137,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { ); return; } + case Screens.JOIN_TEAM: + screen = withServerDatabase(require('@screens/join_team').default); + break; case Screens.LATEX: screen = withServerDatabase(require('@screens/latex').default); break; diff --git a/app/screens/join_team/index.ts b/app/screens/join_team/index.ts new file mode 100644 index 0000000000..46b52cdea8 --- /dev/null +++ b/app/screens/join_team/index.ts @@ -0,0 +1,37 @@ +// 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 {queryMyTeams} from '@queries/servers/team'; +import MyTeamModel from '@typings/database/models/servers/my_team'; + +import JoinTeam from './join_team'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const membershipsToIdSet = (mm: MyTeamModel[]) => { + return new Set(mm.map((m) => m.id)); +}; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + // TODO https://mattermost.atlassian.net/browse/MM-43622 + // 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 joinedIds = queryMyTeams(database).observe().pipe( + switchMap((mm) => of$(membershipsToIdSet(mm))), + ); + + return { + joinedIds, + }; +}); + +export default withDatabase(enhanced(JoinTeam)); diff --git a/app/screens/join_team/join_team.tsx b/app/screens/join_team/join_team.tsx new file mode 100644 index 0000000000..68e804957a --- /dev/null +++ b/app/screens/join_team/join_team.tsx @@ -0,0 +1,155 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {View} from 'react-native'; + +import {addCurrentUserToTeam, fetchTeamsForComponent, handleTeamChange} from '@actions/remote/team'; +import FormattedText from '@components/formatted_text'; +import Empty from '@components/illustrations/no_team'; +import Loading from '@components/loading'; +import TeamList from '@components/team_list'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import {dismissModal} from '@screens/navigation'; +import {logDebug} from '@utils/log'; +import {alertTeamAddError} from '@utils/navigation'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + joinedIds: Set; + componentId: string; + closeButtonId: string; +} +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + paddingHorizontal: 10, + paddingVertical: 5, + flex: 1, + }, + empty: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + loading: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + color: theme.centerChannelColor, + marginTop: 16, + ...typography('Heading', 400, 'Regular'), + }, + description: { + color: theme.centerChannelColor, + marginTop: 8, + maxWidth: 334, + ...typography('Body', 200, 'Regular'), + }, +})); + +export default function JoinTeam({ + joinedIds, + componentId, + closeButtonId, +}: Props) { + const serverUrl = useServerUrl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const intl = useIntl(); + const page = useRef(0); + const hasMore = useRef(true); + const mounted = useRef(true); + const [loading, setLoading] = useState(true); + const [joining, setJoining] = useState(false); + const [otherTeams, setOtherTeams] = useState([]); + + const loadTeams = useCallback(async () => { + setLoading(true); + const resp = await fetchTeamsForComponent(serverUrl, page.current, joinedIds); + page.current = resp.page; + hasMore.current = resp.hasMore; + if (resp.teams.length && mounted.current) { + setOtherTeams((cur) => [...cur, ...resp.teams]); + } + setLoading(false); + }, [joinedIds, serverUrl]); + + const onEndReached = useCallback(() => { + if (hasMore.current && !loading) { + loadTeams(); + } + }, [loadTeams, loading]); + + const onPress = useCallback(async (teamId: string) => { + setJoining(true); + const {error} = await addCurrentUserToTeam(serverUrl, teamId); + if (error) { + alertTeamAddError(error, intl); + logDebug('error joining a team:', error); + setJoining(false); + } else { + handleTeamChange(serverUrl, teamId); + dismissModal({componentId}); + } + }, [serverUrl, componentId, intl]); + + useEffect(() => { + loadTeams(); + return () => { + mounted.current = false; + }; + }, []); + + const onClosePressed = useCallback(() => { + return dismissModal({componentId}); + }, [componentId]); + + useNavButtonPressed(closeButtonId, componentId, onClosePressed, []); + + const hasOtherTeams = Boolean(otherTeams.length); + + let body; + if ((loading && !hasOtherTeams) || joining) { + body = (); + } else if (hasOtherTeams) { + body = ( + + ); + } else { + body = ( + + + + + + ); + } + + return ( + + {body} + + ); +} diff --git a/app/screens/login/form.tsx b/app/screens/login/form.tsx index b5528ea7ed..7d6d7b595f 100644 --- a/app/screens/login/form.tsx +++ b/app/screens/login/form.tsx @@ -18,6 +18,7 @@ import {useIsTablet} from '@hooks/device'; import {t} from '@i18n'; import {goToScreen, loginAnimationOptions, resetToHome, resetToTeams} from '@screens/navigation'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; +import {isServerError} from '@utils/errors'; import {preventDoubleTap} from '@utils/tap'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -139,9 +140,9 @@ const LoginForm = ({config, extra, keyboardAwareRef, numberSSOs, serverDisplayNa const checkLoginResponse = (data: LoginActionResponse) => { let errorId = ''; - const clientError = data.error as ClientErrorProps; - if (clientError && clientError.server_error_id) { - errorId = clientError.server_error_id; + const loginError = data.error; + if (isServerError(loginError) && loginError.server_error_id) { + errorId = loginError.server_error_id; } if (data.failed && MFA_EXPECTED_ERRORS.includes(errorId)) { @@ -150,9 +151,9 @@ const LoginForm = ({config, extra, keyboardAwareRef, numberSSOs, serverDisplayNa return false; } - if (data?.error && data.failed) { + if (loginError && data.failed) { setIsLoading(false); - setError(getLoginErrorMessage(data.error)); + setError(getLoginErrorMessage(loginError)); return false; } diff --git a/app/screens/permalink/index.ts b/app/screens/permalink/index.ts index 1a890bd215..af8c9e8e53 100644 --- a/app/screens/permalink/index.ts +++ b/app/screens/permalink/index.ts @@ -7,7 +7,7 @@ import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; import {observePost} from '@queries/servers/post'; -import {observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system'; +import {observeCurrentTeamId} from '@queries/servers/system'; import {queryMyTeamsByIds, queryTeamByName} from '@queries/servers/team'; import {observeIsCRTEnabled} from '@queries/servers/thread'; @@ -42,7 +42,6 @@ const enhance = withObservables([], ({database, postId, teamName}: OwnProps) => switchMap((ms) => of$(Boolean(ms?.[0]))), ), currentTeamId: observeCurrentTeamId(database), - currentUserId: observeCurrentUserId(database), isCRTEnabled: observeIsCRTEnabled(database), }; }); diff --git a/app/screens/permalink/permalink.tsx b/app/screens/permalink/permalink.tsx index cab55a22e1..b7bd973f56 100644 --- a/app/screens/permalink/permalink.tsx +++ b/app/screens/permalink/permalink.tsx @@ -8,7 +8,7 @@ import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-cont import {fetchChannelById, joinChannel, switchToChannelById} from '@actions/remote/channel'; import {fetchPostById, fetchPostsAround, fetchPostThread} from '@actions/remote/post'; -import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from '@actions/remote/team'; +import {addCurrentUserToTeam, fetchTeamByName, removeCurrentUserFromTeam} from '@actions/remote/team'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import Loading from '@components/loading'; @@ -37,7 +37,6 @@ type Props = { rootId?: string; teamName?: string; isTeamMember?: boolean; - currentUserId: string; currentTeamId: string; isCRTEnabled: boolean; postId: PostModel['id']; @@ -130,7 +129,6 @@ function Permalink({ postId, teamName, isTeamMember, - currentUserId, currentTeamId, }: Props) { const [posts, setPosts] = useState([]); @@ -187,7 +185,7 @@ function Permalink({ joinedTeam = fetchData.team; if (joinedTeam) { - const addData = await addUserToTeam(serverUrl, joinedTeam.id, currentUserId); + const addData = await addCurrentUserToTeam(serverUrl, joinedTeam.id); if (addData.error) { joinedTeam = undefined; } @@ -197,7 +195,7 @@ function Permalink({ const {post} = await fetchPostById(serverUrl, postId, true); if (!post) { if (joinedTeam) { - removeUserFromTeam(serverUrl, joinedTeam.id, currentUserId); + removeCurrentUserFromTeam(serverUrl, joinedTeam.id); } setError({notExist: true}); setLoading(false); @@ -210,7 +208,7 @@ function Permalink({ // Wrong team passed or DM/GM if (joinedTeam && localChannel?.teamId !== '' && localChannel?.teamId !== joinedTeam.id) { - removeUserFromTeam(serverUrl, joinedTeam.id, currentUserId); + removeCurrentUserFromTeam(serverUrl, joinedTeam.id); joinedTeam = undefined; } @@ -233,7 +231,7 @@ function Permalink({ const {channel: fetchedChannel} = await fetchChannelById(serverUrl, post.channel_id); if (!fetchedChannel) { if (joinedTeam) { - removeUserFromTeam(serverUrl, joinedTeam.id, currentUserId); + removeCurrentUserFromTeam(serverUrl, joinedTeam.id); } setError({notExist: true}); setLoading(false); @@ -242,7 +240,7 @@ function Permalink({ // Wrong team passed or DM/GM if (joinedTeam && fetchedChannel.team_id !== '' && fetchedChannel.team_id !== joinedTeam.id) { - removeUserFromTeam(serverUrl, joinedTeam.id, currentUserId); + removeCurrentUserFromTeam(serverUrl, joinedTeam.id); joinedTeam = undefined; } @@ -261,7 +259,7 @@ function Permalink({ const handleClose = useCallback(() => { if (error?.joinedTeam && error.teamId) { - removeUserFromTeam(serverUrl, error.teamId, currentUserId); + removeCurrentUserFromTeam(serverUrl, error.teamId); } dismissModal({componentId: Screens.PERMALINK}); closePermalink(); @@ -288,7 +286,7 @@ function Permalink({ } setChannelId(error.channelId); } - }), [error, serverUrl, currentUserId]); + }), [error, serverUrl]); let content; if (loading) { diff --git a/app/screens/select_team/index.ts b/app/screens/select_team/index.ts index ed83a7fca7..09484f69a2 100644 --- a/app/screens/select_team/index.ts +++ b/app/screens/select_team/index.ts @@ -3,6 +3,8 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; import {queryMyTeams} from '@queries/servers/team'; @@ -18,10 +20,12 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { // switchMap((r) => of$(hasPermission(r, Permissions.CREATE_TEAM, false))), // ); - const nTeams = queryMyTeams(database).observeCount(); - + const myTeams = queryMyTeams(database).observe(); + const nTeams = myTeams.pipe(switchMap((mm) => of$(mm.length))); + const firstTeamId = myTeams.pipe(switchMap((mm) => of$(mm[0]?.id))); return { nTeams, + firstTeamId, }; }); diff --git a/app/screens/select_team/select_team.tsx b/app/screens/select_team/select_team.tsx index 2274306520..0ba14383b2 100644 --- a/app/screens/select_team/select_team.tsx +++ b/app/screens/select_team/select_team.tsx @@ -1,14 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; 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 {addCurrentUserToTeam, fetchTeamsForComponent, handleTeamChange} from '@actions/remote/team'; +import Loading from '@components/loading'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import {logDebug} from '@utils/log'; +import {alertTeamAddError} from '@utils/navigation'; import {makeStyleSheetFromTheme} from '@utils/theme'; import {resetToHome} from '../navigation'; @@ -22,10 +26,16 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ flex: 1, backgroundColor: theme.sidebarBg, }, + loading: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, })); type Props = { nTeams: number; + firstTeamId?: string; } const safeAreaEdges = ['left' as const, 'right' as const]; @@ -33,19 +43,56 @@ const safeAreaStyle = {flex: 1}; const SelectTeam = ({ nTeams, + firstTeamId, }: Props) => { const theme = useTheme(); const styles = getStyleSheet(theme); const serverUrl = useServerUrl(); - const [loading, setLoading] = useState(true); + const intl = useIntl(); const insets = useSafeAreaInsets(); + const resettingToHome = useRef(false); + const [loading, setLoading] = useState(true); + const [joining, setJoining] = useState(false); const top = useAnimatedStyle(() => { return {height: insets.top, backgroundColor: theme.sidebarBg}; }); + const page = useRef(0); + const hasMore = useRef(true); + const mounted = useRef(false); - const [otherTeams, setOtherTeams] = useState(); + const [otherTeams, setOtherTeams] = useState([]); + + const loadTeams = useCallback(async () => { + setLoading(true); + const resp = await fetchTeamsForComponent(serverUrl, page.current); + page.current = resp.page; + hasMore.current = resp.hasMore; + if (resp.teams.length && mounted.current) { + setOtherTeams((cur) => [...cur, ...resp.teams]); + } + setLoading(false); + }, [serverUrl]); + + const onEndReached = useCallback(() => { + if (hasMore.current && !loading) { + loadTeams(); + } + }, [loadTeams, loading]); + + const onTeamPressed = useCallback(async (teamId: string) => { + setJoining(true); + const {error} = await addCurrentUserToTeam(serverUrl, teamId); + if (error) { + alertTeamAddError(error, intl); + logDebug('error joining a team:', error); + + setJoining(false); + } + + // Back to home handled in an effect + }, [serverUrl, intl]); useEffect(() => { mounted.current = true; @@ -55,32 +102,32 @@ const SelectTeam = ({ }, []); useEffect(() => { - if (nTeams > 0) { - resetToHome(); + if (resettingToHome.current) { + return; } - }, [nTeams > 0]); + + if ((nTeams > 0) && firstTeamId) { + resettingToHome.current = true; + handleTeamChange(serverUrl, firstTeamId).then(() => { + resetToHome(); + }); + } + }, [(nTeams > 0) && firstTeamId]); 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); - } - }); + loadTeams(); }, []); let body; - if (loading) { - body = null; - } else if (otherTeams?.length) { + if (joining || (loading && !otherTeams.length)) { + body = ; + } else if (otherTeams.length) { body = ( ); } else { diff --git a/app/screens/select_team/team_list.tsx b/app/screens/select_team/team_list.tsx index abd9d11375..745aa975e2 100644 --- a/app/screens/select_team/team_list.tsx +++ b/app/screens/select_team/team_list.tsx @@ -4,12 +4,9 @@ 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 TeamFlatList from '@components/team_list'; 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'; @@ -39,21 +36,21 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ type Props = { teams: Team[]; + onEndReached: () => void; + onPress: (id: string) => void; + loading: boolean; }; function TeamList({ teams, + onEndReached, + onPress, + loading, }: 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' as const}] : styles.container; }, [isTablet, styles]); @@ -75,7 +72,9 @@ function TeamList({ textColor={theme.sidebarText} iconBackgroundColor={changeOpacity(theme.sidebarText, 0.16)} iconTextColor={theme.sidebarText} - onPress={onTeamAdded} + onPress={onPress} + onEndReached={onEndReached} + loading={loading} /> ); diff --git a/app/store/ephemeral_store.ts b/app/store/ephemeral_store.ts index bfad2e5c33..72f8265d4e 100644 --- a/app/store/ephemeral_store.ts +++ b/app/store/ephemeral_store.ts @@ -1,12 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {BehaviorSubject} from 'rxjs'; + class EphemeralStore { theme: Theme | undefined; creatingChannel = false; creatingDMorGMTeammates: string[] = []; - private pushProxyVerification: {[x: string]: string | undefined} = {}; + private pushProxyVerification: {[serverUrl: string]: string | undefined} = {}; + private canJoinOtherTeams: {[serverUrl: string]: BehaviorSubject} = {}; // 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 @@ -115,6 +118,22 @@ class EphemeralStore { removeSwitchingToChannel = (channelId: string) => { this.switchingToChannel.delete(channelId); }; + + private getCanJoinOtherTeamsSubject = (serverUrl: string) => { + if (!this.canJoinOtherTeams[serverUrl]) { + this.canJoinOtherTeams[serverUrl] = new BehaviorSubject(false); + } + + return this.canJoinOtherTeams[serverUrl]; + }; + + observeCanJoinOtherTeams = (serverUrl: string) => { + return this.getCanJoinOtherTeamsSubject(serverUrl).asObservable(); + }; + + setCanJoinOtherTeams = (serverUrl: string, value: boolean) => { + this.getCanJoinOtherTeamsSubject(serverUrl).next(value); + }; } export default new EphemeralStore(); diff --git a/app/utils/errors.ts b/app/utils/errors.ts new file mode 100644 index 0000000000..30db52c4b3 --- /dev/null +++ b/app/utils/errors.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export function isServerError(obj: unknown): obj is {server_error_id: string; message?: string} { + return ( + typeof obj === 'object' && + obj !== null && + 'server_error_id' in obj && + typeof obj.server_error_id === 'string' + ); +} diff --git a/app/utils/navigation/index.ts b/app/utils/navigation/index.ts index 041d43e29a..eb50c96081 100644 --- a/app/utils/navigation/index.ts +++ b/app/utils/navigation/index.ts @@ -5,7 +5,8 @@ import {IntlShape} from 'react-intl'; import {Alert} from 'react-native'; import {Navigation, Options} from 'react-native-navigation'; -import {Screens} from '@constants'; +import {Screens, ServerErrors} from '@constants'; +import {isServerError} from '@utils/errors'; export const appearanceControlledScreens = new Set([ Screens.ONBOARDING, @@ -72,3 +73,23 @@ export function alertChannelArchived(displayName: string, intl: IntlShape) { }], ); } + +export function alertTeamAddError(error: unknown, intl: IntlShape) { + let errMsg = intl.formatMessage({id: 'join_team.error.message', defaultMessage: 'There has been an error joining the team.'}); + + if (isServerError(error)) { + if (error.server_error_id === ServerErrors.TEAM_MEMBERSHIP_DENIAL_ERROR_ID) { + errMsg = intl.formatMessage({ + id: 'join_team.error.group_error', + defaultMessage: 'You need to be a member of a linked group to join this team.', + }); + } else if (error.message) { + errMsg = error.message; + } + } + + Alert.alert( + intl.formatMessage({id: 'join_team.error.title', defaultMessage: 'Error joining a team'}), + errMsg, + ); +} diff --git a/app/utils/team_list/index.ts b/app/utils/team_list/index.ts index 634b793858..30cbdf10d7 100644 --- a/app/utils/team_list/index.ts +++ b/app/utils/team_list/index.ts @@ -4,7 +4,7 @@ import {ScaledSize} from 'react-native'; import {EdgeInsets} from 'react-native-safe-area-context'; -import {ITEM_HEIGHT} from '@components/team_sidebar/add_team/team_list_item/team_list_item'; +import {ITEM_HEIGHT} from '@components/team_list/team_list_item/team_list_item'; import {PADDING_TOP_MOBILE} from '@screens/bottom_sheet'; import {TITLE_HEIGHT, TITLE_SEPARATOR_MARGIN} from '@screens/bottom_sheet/content'; import {bottomSheetSnapPoint} from '@utils/helpers'; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 1b963926d9..046b094c59 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -326,6 +326,9 @@ "intro.welcome.public": "Add some more team members to the channel or start a conversation below.", "invite_people_to_team.message": "Here’s a link to collaborate and communicate with us on Mattermost.", "invite_people_to_team.title": "Join the {team} team", + "join_team.error.group_error": "You need to be a member of a linked group to join this team.", + "join_team.error.message": "There has been an error joining the team", + "join_team.error.title": "Error joining a team", "last_users_message.added_to_channel.type": "were **added to the channel** by {actor}.", "last_users_message.added_to_team.type": "were **added to the team** by {actor}.", "last_users_message.first": "{firstUser} and ",