// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {defineMessages, useIntl} from 'react-intl'; import {Alert, Platform, Text, TouchableOpacity, View} from 'react-native'; import {KeyboardProvider} from 'react-native-keyboard-controller'; import Animated from 'react-native-reanimated'; import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; import {getPosts} from '@actions/local/post'; import {fetchChannelById, joinChannel, switchToChannelById} from '@actions/remote/channel'; import {fetchPostById, fetchPostInfo, fetchPostsAround, fetchPostThread} from '@actions/remote/post'; import {addCurrentUserToTeam, fetchTeamByName, removeCurrentUserFromTeam} from '@actions/remote/team'; import Button from '@components/button'; 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 useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useIsTablet} from '@hooks/device'; import {usePreventDoubleTap} from '@hooks/utils'; import {getChannelById, getMyChannel} from '@queries/servers/channel'; import {navigateBack} from '@screens/navigation'; import {closePermalink} from '@utils/permalink'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import PermalinkError from './permalink_error'; import type {Database} from '@nozbe/watermelondb'; import type ChannelModel from '@typings/database/models/servers/channel'; import type PostModel from '@typings/database/models/servers/post'; type Props = { channel?: ChannelModel; database: Database; rootId?: string; teamName?: string; isTeamMember?: boolean; currentTeamId: string; isCRTEnabled: boolean; hasPostInfoEndpoint: boolean; postId: PostModel['id']; } const messages = defineMessages({ joinTeamErrorTitle: {id: 'permalink.error.join_team.title', defaultMessage: 'Error joining the team'}, joinTeamErrorMessage: {id: 'permalink.error.join_team.message', defaultMessage: 'There was an error trying to join the team'}, joinChannelErrorTitle: {id: 'permalink.error.join_channel.title', defaultMessage: 'Error joining the channel'}, joinChannelErrorMessage: {id: 'permalink.error.join_channel.message', defaultMessage: 'There was an error trying to join the channel'}, }); 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, minWidth: 0, paddingRight: 40, }, title: { color: theme.centerChannelColor, flexShrink: 1, maxWidth: '100%', ...typography('Heading', 300), }, description: { color: theme.centerChannelColor, flexShrink: 1, maxWidth: '100%', ...typography('Body', 100), }, postList: { flex: 1, }, loading: { flex: 1, justifyContent: 'center', alignItems: 'center', }, footer: { padding: 20, 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; const idExtractor = (item: Post) => { return item.id; }; function Permalink({ channel, database, rootId, isCRTEnabled, hasPostInfoEndpoint, postId, teamName, isTeamMember, currentTeamId, }: Props) { const intl = useIntl(); const [posts, setPosts] = useState([]); 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(); 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) { const myChannel = await getMyChannel(database, channelId); if (!myChannel) { setChannelId(undefined); return; } 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) { const ids = data.posts.map(idExtractor); const postsModels = await getPosts(serverUrl, ids, 'desc'); setPosts(loadThreadPosts ? processThreadPosts(postsModels, postId) : postsModels); } setLoading(false); return; } // Try getPostInfo first (GET /posts/{id}/info, available since server v7.0). // Returns channel/team metadata without requiring membership, so we can // show the join UI without speculatively joining the team first. if (hasPostInfoEndpoint) { const {postInfo} = await fetchPostInfo(serverUrl, postId); if (postInfo && !postInfo.has_joined_channel) { setError({ privateChannel: postInfo.channel_type === 'P', needsTeamJoin: !postInfo.has_joined_team && postInfo.team_id !== '', channelId: postInfo.channel_id, channelName: postInfo.channel_display_name, teamId: postInfo.team_id || currentTeamId, teamName: postInfo.team_display_name, privateTeam: postInfo.team_type === 'I', }); setLoading(false); return; } } // Fallback for old servers (getPostInfo unavailable) or when the user // already has channel access (has_joined_channel === true). // This path speculatively joins the team before fetching the post. 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); })(); // - serverUrl is stable from useServerUrl hook (doesn't need to be in deps) // - postId, isTeamMember, currentTeamId are props that don't change for a given permalink screen // - setError, setLoading, setChannelId, setPosts are stable setState functions // - We only need to re-run when channelId, rootId, isCRTEnabled, or teamName changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [channelId, rootId, isCRTEnabled, teamName]); const handleClose = useCallback(() => { if (error?.joinedTeam && error.teamId) { removeCurrentUserFromTeam(serverUrl, error.teamId); } navigateBack(); closePermalink(); }, [error?.joinedTeam, error?.teamId, serverUrl]); useAndroidHardwareBackHandler(Screens.PERMALINK, handleClose); const handlePress = usePreventDoubleTap(useCallback(async() => { if (channel) { await navigateBack(); switchToChannelById(serverUrl, channel.id, channel.teamId); } }, [channel, serverUrl])); const handleJoin = usePreventDoubleTap(useCallback(async () => { setLoading(true); setError(undefined); if (error?.teamId && error.channelId) { if (error.needsTeamJoin) { const {error: teamError} = await addCurrentUserToTeam(serverUrl, error.teamId); if (teamError) { Alert.alert( intl.formatMessage(messages.joinTeamErrorTitle), intl.formatMessage(messages.joinTeamErrorMessage), ); setLoading(false); setError(error); return; } } const {error: joinError} = await joinChannel(serverUrl, error.teamId, error.channelId); if (joinError) { if (error.needsTeamJoin) { removeCurrentUserFromTeam(serverUrl, error.teamId); } Alert.alert( intl.formatMessage(messages.joinChannelErrorTitle), intl.formatMessage(messages.joinChannelErrorMessage), ); setLoading(false); setError(error); return; } setChannelId(error.channelId); } }, [error, intl, serverUrl])); let content; if (loading) { content = ( ); } else if (error) { content = ( ); } else if (channel) { const postListContent = ( <>