Fix several issues around team join (#6863)

* Fix several issues around team join

* Open in modal and fix channel list

* Add joining states and fix issues

* i18n-extract

* add specific message for group related failures on joining teams

* Address feedback

* Address feedback

* Use error from server response
This commit is contained in:
Daniel Espino García
2022-12-23 13:43:59 +01:00
committed by GitHub
parent 4e3531fb52
commit b1e4403768
35 changed files with 608 additions and 315 deletions

View File

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

View File

@@ -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<string>()]);
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 || '';
}

View File

@@ -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<Post>, 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<Post>, 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) => {

View File

@@ -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<MyTeamsRequest> => {
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<string>, page: number): Promise<boolean> => {
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<string>,
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);
}

View File

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

View File

@@ -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<Promise<Model[]>> = [];
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<Promise<Model[]>> = [];
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());
};

View File

@@ -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<Team|TeamModel>) => {
return (
<TeamListItem
@@ -44,6 +48,11 @@ export default function TeamList({teams, textColor, iconTextColor, iconBackgroun
);
}, [textColor, iconTextColor, iconBackgroundColor, onPress, selectedTeamId]);
let footer;
if (loading) {
footer = (<Loading/>);
}
return (
<View style={styles.container}>
<FlatList
@@ -52,6 +61,8 @@ export default function TeamList({teams, textColor, iconTextColor, iconBackgroun
keyExtractor={keyExtractor}
contentContainerStyle={styles.contentContainer}
testID={`${testID}.flat_list`}
onEndReached={onEndReached}
ListFooterComponent={footer}
/>
</View>
);

View File

@@ -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 (
<BottomSheetContent
buttonIcon='plus'
buttonText={intl.formatMessage({id: 'mobile.add_team.create_team', defaultMessage: 'Create a new team'})}
onPress={onPressCreate}
showButton={false}
showTitle={showTitle}
testID='team_sidebar.add_team_slide_up'
title={title}
>
{hasOtherTeams &&
<TeamList
teams={otherTeams}
onPress={onPress}
testID='team_sidebar.add_team_slide_up.team_list'
/>
}
{!hasOtherTeams &&
<View style={styles.empty}>
<Empty theme={theme}/>
<FormattedText
id='team_list.no_other_teams.title'
defaultMessage='No additional teams to join'
style={styles.title}
testID='team_sidebar.add_team_slide_up.no_other_teams.title'
/>
<FormattedText
id='team_list.no_other_teams.description'
defaultMessage='To join another team, ask a Team Admin for an invitation, or create your own team.'
style={styles.description}
testID='team_sidebar.add_team_slide_up.no_other_teams.description'
/>
</View>
}
</BottomSheetContent>
);
}

View File

@@ -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 (
<AddTeamSlideUp
otherTeams={otherTeams}
showTitle={!isTablet && Boolean(otherTeams.length)}
title={title}
/>
);
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 (
<View style={styles.container}>

View File

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

View File

@@ -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) {
<Animated.View style={[styles.container, transform]}>
<Animated.View style={[styles.listContainer, serverStyle]}>
<TeamList testID='team_sidebar.team_list'/>
{showAddTeam && (
<AddTeam
otherTeams={otherTeams}
/>
{canJoinOtherTeams && (
<AddTeam/>
)}
</Animated.View>
</Animated.View>

View File

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

View File

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

View File

@@ -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`,

View File

@@ -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<Promise<Model[]>> = [];
const preparedCategories = prepareCategories(operator, categories);
if (preparedCategories) {
modelPromises.push(preparedCategories);
}
const preparedCategoryChannels = prepareCategoryChannels(operator, categories);
if (preparedCategoryChannels) {
modelPromises.push(preparedCategoryChannels);
}
const modelPromises: Array<Promise<Model[]>> = [
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 [];
}
}

View File

@@ -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<TeamModel>(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);

View File

@@ -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: {

View File

@@ -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),

View File

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

View File

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

View File

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

View File

@@ -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<string>;
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<Team[]>([]);
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 = (<Loading containerStyle={styles.loading}/>);
} else if (hasOtherTeams) {
body = (
<TeamList
teams={otherTeams}
onPress={onPress}
testID='team_sidebar.add_team_slide_up.team_list'
onEndReached={onEndReached}
loading={loading}
/>
);
} else {
body = (
<View style={styles.empty}>
<Empty theme={theme}/>
<FormattedText
id='team_list.no_other_teams.title'
defaultMessage='No additional teams to join'
style={styles.title}
testID='team_sidebar.add_team_slide_up.no_other_teams.title'
/>
<FormattedText
id='team_list.no_other_teams.description'
defaultMessage='To join another team, ask a Team Admin for an invitation, or create your own team.'
style={styles.description}
testID='team_sidebar.add_team_slide_up.no_other_teams.description'
/>
</View>
);
}
return (
<View style={styles.container}>
{body}
</View>
);
}

View File

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

View File

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

View File

@@ -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<PostModel[]>([]);
@@ -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) {

View File

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

View File

@@ -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<Team[]>();
const [otherTeams, setOtherTeams] = useState<Team[]>([]);
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 = <Loading containerStyle={styles.loading}/>;
} else if (otherTeams.length) {
body = (
<TeamList
teams={otherTeams}
onEndReached={onEndReached}
onPress={onTeamPressed}
loading={loading}
/>
);
} else {

View File

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

View File

@@ -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<boolean>} = {};
// 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();

11
app/utils/errors.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -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": "Heres 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 ",