Files
mattermost-mobile/app/screens/permalink/permalink.tsx
Daniel Espino García b1e4403768 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
2022-12-23 14:43:59 +02:00

412 lines
14 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Alert, Text, TouchableOpacity, View} from 'react-native';
import Animated from 'react-native-reanimated';
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {fetchChannelById, joinChannel, switchToChannelById} from '@actions/remote/channel';
import {fetchPostById, fetchPostsAround, fetchPostThread} from '@actions/remote/post';
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';
import PostList from '@components/post_list';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import {getChannelById, getMyChannel} from '@queries/servers/channel';
import {dismissModal} from '@screens/navigation';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {closePermalink} from '@utils/permalink';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import PermalinkError from './permalink_error';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
type Props = {
channel?: ChannelModel;
rootId?: string;
teamName?: string;
isTeamMember?: boolean;
currentTeamId: string;
isCRTEnabled: boolean;
postId: PostModel['id'];
}
const edges: Edge[] = ['left', 'right', 'top'];
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
maxWidth: 680,
alignSelf: 'center',
width: '100%',
},
wrapper: {
backgroundColor: theme.centerChannelBg,
borderRadius: 12,
flex: 1,
margin: 10,
opacity: 1,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
},
header: {
alignItems: 'center',
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
flexDirection: 'row',
height: 56,
paddingRight: 16,
width: '100%',
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
},
close: {
justifyContent: 'center',
height: 44,
width: 40,
paddingLeft: 16,
},
titleContainer: {
alignItems: 'center',
flex: 1,
paddingRight: 40,
},
title: {
color: theme.centerChannelColor,
...typography('Heading', 300),
},
description: {
color: theme.centerChannelColor,
...typography('Body', 100),
},
postList: {
flex: 1,
},
loading: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
footer: {
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
padding: 20,
width: '100%',
borderBottomLeftRadius: 12,
borderBottomRightRadius: 12,
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.16),
},
jump: {
color: theme.buttonColor,
fontSize: 15,
fontWeight: '600',
textAlignVertical: 'center',
},
}),
);
const POSTS_LIMIT = 5;
function Permalink({
channel,
rootId,
isCRTEnabled,
postId,
teamName,
isTeamMember,
currentTeamId,
}: Props) {
const [posts, setPosts] = useState<PostModel[]>([]);
const [loading, setLoading] = useState(true);
const theme = useTheme();
const serverUrl = useServerUrl();
const insets = useSafeAreaInsets();
const isTablet = useIsTablet();
const style = getStyleSheet(theme);
const [error, setError] = useState<PermalinkErrorType>();
const [channelId, setChannelId] = useState(channel?.id);
const containerStyle = useMemo(() => {
const marginTop = isTablet ? 60 : 20;
const marginBottom = insets.bottom + (isTablet ? 60 : 20);
return [style.container, {marginTop, marginBottom}];
}, [style, insets.bottom, isTablet]);
useEffect(() => {
(async () => {
if (channelId) {
let data;
const loadThreadPosts = isCRTEnabled && rootId;
if (loadThreadPosts) {
data = await fetchPostThread(serverUrl, rootId, {
fetchAll: true,
});
} else {
data = await fetchPostsAround(serverUrl, channelId, postId, POSTS_LIMIT, isCRTEnabled);
}
if (data.error) {
setError({unreachable: true});
}
if (data.posts) {
setPosts(loadThreadPosts ? processThreadPosts(data.posts, postId) : data.posts);
}
setLoading(false);
return;
}
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
setError({unreachable: true});
setLoading(false);
return;
}
// If a team is provided, try to join the team, but do not fail here, to take into account:
// - Wrong team name
// - DMs/GMs
let joinedTeam: Team | undefined;
if (teamName && !isTeamMember) {
const fetchData = await fetchTeamByName(serverUrl, teamName, true);
joinedTeam = fetchData.team;
if (joinedTeam) {
const addData = await addCurrentUserToTeam(serverUrl, joinedTeam.id);
if (addData.error) {
joinedTeam = undefined;
}
}
}
const {post} = await fetchPostById(serverUrl, postId, true);
if (!post) {
if (joinedTeam) {
removeCurrentUserFromTeam(serverUrl, joinedTeam.id);
}
setError({notExist: true});
setLoading(false);
return;
}
const myChannel = await getMyChannel(database, post.channel_id);
if (myChannel) {
const localChannel = await getChannelById(database, myChannel.id);
// Wrong team passed or DM/GM
if (joinedTeam && localChannel?.teamId !== '' && localChannel?.teamId !== joinedTeam.id) {
removeCurrentUserFromTeam(serverUrl, joinedTeam.id);
joinedTeam = undefined;
}
if (joinedTeam) {
setError({
joinedTeam: true,
channelId: myChannel.id,
channelName: localChannel?.displayName,
privateTeam: !joinedTeam.allow_open_invite,
teamName: joinedTeam.display_name,
teamId: joinedTeam.id,
});
setLoading(false);
return;
}
setChannelId(post.channel_id);
return;
}
const {channel: fetchedChannel} = await fetchChannelById(serverUrl, post.channel_id);
if (!fetchedChannel) {
if (joinedTeam) {
removeCurrentUserFromTeam(serverUrl, joinedTeam.id);
}
setError({notExist: true});
setLoading(false);
return;
}
// Wrong team passed or DM/GM
if (joinedTeam && fetchedChannel.team_id !== '' && fetchedChannel.team_id !== joinedTeam.id) {
removeCurrentUserFromTeam(serverUrl, joinedTeam.id);
joinedTeam = undefined;
}
setError({
privateChannel: fetchedChannel.type === 'P',
joinedTeam: Boolean(joinedTeam),
channelId: fetchedChannel.id,
channelName: fetchedChannel.display_name,
teamId: fetchedChannel.team_id || currentTeamId,
teamName: joinedTeam?.display_name,
privateTeam: joinedTeam && !joinedTeam.allow_open_invite,
});
setLoading(false);
})();
}, [channelId, rootId, isCRTEnabled, teamName]);
const handleClose = useCallback(() => {
if (error?.joinedTeam && error.teamId) {
removeCurrentUserFromTeam(serverUrl, error.teamId);
}
dismissModal({componentId: Screens.PERMALINK});
closePermalink();
}, [error]);
useAndroidHardwareBackHandler(Screens.PERMALINK, handleClose);
const handlePress = useCallback(preventDoubleTap(() => {
if (channel) {
switchToChannelById(serverUrl, channel.id, channel.teamId);
}
}), [channel?.id, channel?.teamId]);
const handleJoin = useCallback(preventDoubleTap(async () => {
setLoading(true);
setError(undefined);
if (error?.teamId && error.channelId) {
const {error: joinError} = await joinChannel(serverUrl, error.teamId, error.channelId);
if (joinError) {
Alert.alert('Error joining the channel', 'There was an error trying to join the channel');
setLoading(false);
setError(error);
return;
}
setChannelId(error.channelId);
}
}), [error, serverUrl]);
let content;
if (loading) {
content = (
<View style={style.loading}>
<Loading
color={theme.buttonBg}
/>
</View>
);
} else if (error) {
content = (
<PermalinkError
error={error}
handleClose={handleClose}
handleJoin={handleJoin}
/>
);
} else {
content = (
<>
<View style={style.postList}>
<PostList
highlightedId={postId}
isCRTEnabled={isCRTEnabled}
posts={posts}
location={Screens.PERMALINK}
lastViewedAt={0}
shouldShowJoinLeaveMessages={false}
channelId={channel!.id}
rootId={rootId}
testID='permalink.post_list'
nativeID={Screens.PERMALINK}
highlightPinnedOrSaved={false}
/>
</View>
<View style={style.footer}>
<TouchableOpacity
style={[buttonBackgroundStyle(theme, 'lg', 'primary'), {width: '100%'}]}
onPress={handlePress}
testID='permalink.jump_to_recent_messages.button'
>
<FormattedText
testID='permalink.search.jump'
id='mobile.search.jump'
defaultMessage='Jump to recent messages'
style={buttonTextStyle(theme, 'lg', 'primary')}
/>
</TouchableOpacity>
</View>
</>
);
}
const showHeaderDivider = Boolean(channel?.displayName) && !error && !loading;
return (
<SafeAreaView
style={containerStyle}
testID='permalink.screen'
edges={edges}
>
<Animated.View style={style.wrapper}>
<View style={style.header}>
<TouchableOpacity
style={style.close}
onPress={handleClose}
>
<CompassIcon
name='close'
size={24}
color={theme.centerChannelColor}
/>
</TouchableOpacity>
<View style={style.titleContainer}>
{isCRTEnabled && rootId ? (
<FormattedText
id='thread.header.thread'
defaultMessage='Thread'
style={style.title}
/>
) : (
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={style.title}
>
{channel?.displayName}
</Text>
)}
{Boolean(isCRTEnabled && rootId) && (
<FormattedText
ellipsizeMode='tail'
id='thread.header.thread_in'
defaultMessage='in {channelName}'
values={{
channelName: channel?.displayName,
}}
style={style.description}
/>
)}
</View>
</View>
{showHeaderDivider && (
<View>
<View style={style.divider}/>
</View>
)}
{content}
</Animated.View>
</SafeAreaView>
);
}
// Get the posts around the focused post
function processThreadPosts(posts: PostModel[], postId: string) {
posts.sort((a, b) => b.createAt - a.createAt);
const postIndex = posts.findIndex((p) => p.id === postId);
const start = postIndex - POSTS_LIMIT;
return posts.slice(start < 0 ? postIndex : start, postIndex + POSTS_LIMIT + 1);
}
export default Permalink;